HackThisSite - Application Mission 11

Challenge

Application Challenge 11 (Windows) — Find the password. (medium) 目标:从这个 Windows GUI 程序里找出 password。

包内只有一个 app11win.exe(40 KB,PE32 GUI)。程序是个 Visual Basic 6 写的窗口,界面上有一个 Text1 输入框和一个标题为 Check 的按钮:典型的输入密码点确认形态。真正的东西不在代码里,而在程序随身带着的一张图片里:password 被画成文字印在内嵌的 JPEG 上。这关和 App 15 是同一类题:秘密必须由客户端渲染,所以逆向者一定能拿到。

Solution

  • file app11win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386, 3 sections;导入表只有 MSVBVM60.DLLVB6 编译的程序
  • strings 里能看到控件名和工程信息:Form1Command1CheckText1App Challengechallenge5Project1.text 段偏移 0x54BC 处有 VB 工程头魔数 VB5!。入口点把 &DAT_004054bc(就是这个工程头)交给 VB 运行时初始化,是标准的 VB6 启动流程。
  • 二进制里 搜不到任何明文答案Search / Destroy / Awnser / Answer / password 在 ASCII 和 UTF-16 两种编码下命中数都是 0。密码是一组像素。
  • binwalk app11win.exe 直接报出 JPEG:
1
2
3
4
5
$ binwalk app11win.exe
DECIMAL HEXADECIMAL DESCRIPTION
------------------------------------------------------------------------
0 0x0 Windows PE binary, machine type: Intel x86
4766 0x129E JPEG image, total size: 4536 bytes

Step 1: 提取内嵌图片

binwalk 只给出了一个 blob,但那实际上是两个叠加在一起的 JPEG。手工遍历一遍 JPEG marker 才能看清:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- walk from 0x129E ---
0x129E: FFD8
0x12A0: FFE0 len=16 JFIF
0x12B2: FFE1 len=1710 Exif <- 内含一张 160 字节的 Exif 缩略图
0x1962: FFED len=2920 Photoshop <- 2300 字节的 APP13,里面塞了一整张独立 JPEG
0x24CC: FFE1 len=4680 XMP <- APP1 XMP 元数据
0x3716: FFEE len=14 APP14 <- Adobe 标记
0x3726: FFDB len=132 DQT <- 量化表
0x37AC: FFC0 len=17 b'\x08\x00:\x00\xe9\x03...' <- SOF0: 高 0x3A=58, 宽 0xE9=233
0x3969: FFDA len=12 SOS
...scan data then EOI at 0x4B35

--- walk from 0x1ED6 ---
0x1ED6: FFD8
0x1ED8: FFE0 len=16 JFIF
0x1F8E: FFC0 len=17 b'\x08\x00 \x00\x80\x03...' <- SOF0: 高 0x20=32, 宽 0x80=128
0x20E8: FFDA len=12 SOS
...scan data then EOI at 0x2454

结论:

  • image A:从 0x129E0x4B35233×58,14489 字节。它不能按第一个 FF D9 截断:A 的 APP1 Exif 段里那张缩略图的 EOI(0x1960)会先命中,截出来的图打不开(identify 直接报错),脚本因此先定位 SOS 再找它后面第一个真正的 EOI。
  • image B:从 0x1ED60x2454128×32,1408 字节;它整张位于 A 的 APP13(Photoshop IRB)段内,本质是 A 的缩略图(把 A 缩到 128×32 与 B 逐像素比,平均绝对差只有 5.2/255,内容一致);它只有 1408 字节,像素量不足以读出可靠文本,读数只能以 image A 为依据。

完整脚本:

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
"""Carve the two real JPEGs out of app11win.exe.

Marker walk shows:
* JPEG A at 0x129E: SOI, JFIF, APP1 Exif(1710, embeds a 160-byte thumbnail),
APP13 Photoshop(2920, embeds the whole of JPEG B), APP1 XMP, ... SOF0 233x58,
SOS, scan data, EOI at 0x4B35.
* JPEG B at 0x1ED6: complete 128x32 baseline JPEG living inside A's APP13.

The naive "first FF D9" carve truncates A at the Exif thumbnail's EOI, so we
locate SOS explicitly and take everything up to the first real EOI after it.
"""
import struct

data = open("app11win.exe", "rb").read()


def carve(soi):
"""Return (start, end) of a full JPEG beginning at `soi`."""
p = soi + 2
while p < len(data) - 1:
while data[p] != 0xFF:
p += 1
m = data[p + 1]
if m == 0xDA: # SOS -> skip header, then scan for EOI
ln = struct.unpack_from(">H", data, p + 2)[0]
p = p + 2 + ln
break
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
p += 2
continue
if m == 0xFF:
p += 1
continue
ln = struct.unpack_from(">H", data, p + 2)[0]
p = p + 2 + ln
# scan entropy-coded data for EOI (FF D9)
q = p
while q < len(data) - 1:
if data[q] == 0xFF and data[q + 1] == 0xD9:
return soi, q + 2
q += 1
return soi, len(data)


for name, off in (("image_a.jpg", 0x129E), ("image_b.jpg", 0x1ED6)):
s, e = carve(off)
blob = data[s:e]
open("extracted/" + name, "wb").write(blob)
print(f"{name}: 0x{s:X}..0x{e:X} size={len(blob)}")
1
2
3
4
5
6
7
$ cd <hts-workspace>/challenges/hts-app/app11 && uv run python carve_jpegs.py
image_a.jpg: 0x129E..0x4B37 size=14489
image_b.jpg: 0x1ED6..0x2456 size=1408

$ file extracted/image_a.jpg extracted/image_b.jpg
extracted/image_a.jpg: JPEG image data, JFIF ... baseline, precision 8, 233x58, components 3
extracted/image_b.jpg: JPEG image data, JFIF ... baseline, precision 8, 128x32, components 3

Step 2: 逐字形读字

把白色文字二值化,裁出文字行(x=25..207, y=33..43),按整列无像素切字形,逐个打成 #/. 位图直接读,得到 The Awnser is: Search&Destroy(原图自己把 Answer 拼成了 Awnser)。脚本:

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
"""Read the text baked into image_a.jpg glyph by glyph.

We threshold the white text, crop the single text line, split it into glyph
columns on fully-blank separators, and print each glyph as a #/. bitmap so the
string can be read by eye.
"""
from PIL import Image

img = Image.open("extracted/image_a.jpg").convert("L")
W, H = img.size
px = img.load()

# White text on a dark red->black gradient: keep bright pixels.
THRESH = 170
rows = [[1 if px[x, y] > THRESH else 0 for x in range(W)] for y in range(H)]


def bbox(grid):
ys = [y for y, r in enumerate(grid) if any(r)]
xs = [x for x in range(len(grid[0])) if any(r[x] for r in grid)]
return min(xs), min(ys), max(xs), max(ys)


x0, y0, x1, y1 = bbox(rows)
print(f"image {W}x{H}; text bbox x={x0}..{x1} y={y0}..{y1}")
line = [r[x0:x1 + 1] for r in rows[y0:y1 + 1]]

# Column occupancy -> split into glyphs on blank columns.
ncols = len(line[0])
col_has = [any(r[c] for r in line) for c in range(ncols)]
glyphs = []
c = 0
while c < ncols:
if col_has[c]:
s = c
while c < ncols and col_has[c]:
c += 1
glyphs.append((s, c - 1))
else:
c += 1

print(f"{len(glyphs)} raw glyph runs\n")
for i, (s, e) in enumerate(glyphs):
sub = [r[s:e + 1] for r in line]
ys = [y for y, r in enumerate(sub) if any(r)]
sub = sub[min(ys):max(ys) + 1]
print(f"glyph {i:02d} x={x0+s}..{x0+e} w={e-s+1} h={len(sub)}")
for r in sub:
print(" " + "".join("#" if v else "." for v in r))
print()
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
$ cd <hts-workspace>/challenges/hts-app/app11 && uv run python read_glyphs.py
image 233x58; text bbox x=25..207 y=33..43
23 raw glyph runs

glyph 00 x=25..31 w=7 h=8 glyph 01 x=33..38 w=6 h=9 glyph 02 x=40..45 w=6 h=6
####### #..... .####.
...#... #..... ##..##
...#... #..... ######
...#... #####. #.....
...#... ##..## .#...#
...#... #...## ..###.
...#... #...##
...#... #...## = 'e'
#...##
= 'T' = 'h'

glyph 03 x=50..66 w=17 h=8 (合并了 'A' + 'w')
...##............
..####...........
..#..#..##..##..#
..#..#...#..##..#
.##..##..#...#..#
.######..#.#...#.
##....##..##..##.
#......#..##..##.
'A'=cols0..7 'w'=cols8..16

glyph 15 x=148..153 w=6 h=8 glyph 16 x=156..162 w=7 h=9 glyph 17 x=165..171 w=7 h=8
##.... ..##... ######.
##.... .#..#.. ##...##
#####. .#..#.. ##....#
##..## .###... ##....#
##...# .###... ##....#
##...# #..#### ##....#
##...# #...##. ##...##
##...# ##..### ##...#.
.###..# #####..
= 'h' = '&' = 'D'

23 个字形连起来读:

1
2
run : 0  1  2  |  3(A+w)  4  5  6  7  |  8  9  10 | 11 12 13 14(rc) 15 | 16 | 17 18 19 20 21(ro) 22
char: T h e | A w n s e r | i s : | S e a r c h | & | D e s t r o y

The Awnser is: Search&Destroyh& 之间、&D 之间的空隙都是 2 像素,和词内字符间距一样,所以 & 两侧没有空格)。逐字形读数与脚本输出一致;& 这种符号靠手工切图确认。

Step 3: Ghidra 无头反编译

1
2
3
4
5
$ /opt/ghidra/support/analyzeHeadless \
<hts-workspace>/challenges/hts-app/app11/ghidra_proj app11proj \
-import app11/app11win.exe \
-scriptPath <hts-workspace>/challenges/hts-app/ghidra_scripts \
-postScript ghidra_scripts/DecompileAll.java > out/ghidra_log.txt 2>&1

Ghidra 只恢复了 5 个函数(__vbaVarDupOrdinal_100entryFUN_00405e30FUN_00405ec0),说明程序逻辑几乎全在 VB 运行时里,用户代码只有一个窗体。关键的 entry

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void entry(void)
{
byte bVar1;
char cVar2;
byte *pbVar3;
char *extraout_ECX;

pbVar3 = (byte *)Ordinal_100(&DAT_004054bc); // DAT_004054bc = "VB5!" 工程头
bVar1 = (byte)pbVar3;
*pbVar3 = *pbVar3 + bVar1;
*pbVar3 = *pbVar3 + bVar1;
*pbVar3 = *pbVar3 + bVar1;
*pbVar3 = *pbVar3 ^ bVar1;
*pbVar3 = *pbVar3 + bVar1;
pbVar3 = pbVar3 + 1;
cVar2 = (char)pbVar3;
*pbVar3 = *pbVar3 + cVar2;
*pbVar3 = *pbVar3 + cVar2;
*pbVar3 = *pbVar3 + cVar2;
*extraout_ECX = *extraout_ECX + (char)((uint)pbVar3 >> 8);
__vbaVarDup();
return;
}

entry&DAT_004054bc(就是 .text 偏移 0x54BC 处的 VB 工程头)交给 MSVBVM60.DLL 去初始化,和 VB6 标准启动一致。窗体初始化函数 FUN_00405ec0 里全是 __vbaStrCopy / __vbaObjSet / Ordinal_528 / Ordinal_632 这类 VB 运行时调用,没有任何一处显式加载或绘制图片的调用(没有 LoadPictureOleLoadPictureDrawImage 之类)。原因就是 VB6 的设计期 Picture 属性不会生成用户可见的调用:图片数据直接以数据形式躺在 .text 段里,窗体属性表在运行时把它喂给运行时去解码。两段 JPEG 的存放位置也印证了这一点:

1
2
jpeg A file offset 0x129E -> .text RVA 0x129E  (VA 0x40129E)
jpeg B file offset 0x1ED6 -> .text RVA 0x1ED6 (VA 0x401ED6)

窗体/控件名字表也紧挨在 VB 工程头前面(0x5407 起:Form1 Command1 Check Text1),确认这就是那个带输入框和 Check 按钮的窗口。

代码侧能给出的结论就是反证:全二进制没有任何明文 password,唯一的秘密来源是那张内嵌图片;而图片里的文字已经由 Step 2 定案。

Static

定案来自静态提取 + 逐字形位图读数,没有在窗口里实际输入 Search&Destroy 走一遍 Check

Vulnerabilities

凭据以图形形式嵌入客户端可执行文件,恢复成本仅为读出图中的文字。需要客户端渲染或比对的秘密,逆向者必然能取得其明文或等价物。修复方向:校验放在服务端,客户端只做不可信展示;确需本地校验时保存不可逆的校验值(哈希),避免把明文写进随包分发的图片。

Search&Destroy