Hello Navi

Tech, Security & Personal Notes

Challenge

题目提供 stego14.tar.gz,提示 Get the image here。需要先看清 tar 内部结构,再从图片里挖出隐藏的密钥与密文,最后用古典密码还原口令。 A tar.gz is provided; the answer is recovered by unpacking it, carving a RAR archive out of the JPG, and decrypting a classical cipher with a key that is itself hidden inside the archive.

Solution

1
2
3
$ tar tzf stego14.tar.gz
6578747261637400.jpg
$ tar xzf stego14.tar.gz

成员名 6578747261637400 是一串 ASCII 十六进制,解码后是 extract\0。这个名字在提示真正的载荷不在图片像素里,而在图片之后附加的数据里。

图片本体是一张 150x20 的基线 JPEG:

1
2
$ file 6578747261637400.jpg
JPEG image data, JFIF standard 1.02, baseline, precision 8, 150x20, components 3

JPEG 以 EOI(FF D9)结束,但文件真实长度超过了 EOI 的位置,后面还跟了一段数据。扫描标记来定位:

1
2
3
4
5
6
data = open("6578747261637400.jpg", "rb").read()
eoi = data.rfind(b"\xff\xd9")
print("file len :", len(data))
print("EOI at :", eoi)
print("trailing :", len(data) - eoi - 2, "bytes")
print("signature:", data[eoi + 2:eoi + 9])

输出显示 EOI 之后还有 5617 字节,且以 Rar! 开头,是一段 RAR v4 归档。把它切出来解开:

1
2
3
4
5
6
7
8
$ python3 -c "d=open('6578747261637400.jpg','rb').read(); i=d.rfind(b'\xff\xd9'); open('appended.rar','wb').write(d[i+2:])"
$ file appended.rar
appended.rar: RAR archive data, v4, os: Win32
$ 7z l appended.rar
Date Time Attr Size Compressed Name
------------------- ----- ------------ ------------ ------------------------
2009-01-23 01:02:14 ....A 12758 5546 key.jpg
$ 7z x appended.rar

归档里只有一个文件 key.jpg(48x13)。

key.jpg 是纯黑白位图,像素值只有黑(0)与白(255)两色,放大后是一行小字。直接按 ASCII 打印像素即可读:

1
2
3
4
5
6
from PIL import Image

im = Image.open("key.jpg").convert("L")
w, h = im.size
for y in range(h):
print("".join("#" if im.getpixel((x, y)) < 128 else "." for x in range(w)))

打印出来的是 *5+10。这正是仿射密码(Affine cipher)的密钥,含义为 E(x) = (5 * x + 10) mod 26,也就是 a = 5, b = 10

6578747261637400.jpg 的像素对比度极低,灰度只落在 197–255 之间,肉眼看上去几乎全白。把灰度拉伸到完整范围后,里面藏着一行大写密文 PGNNZCFYXD

1
2
3
4
5
6
from PIL import Image
import numpy as np

a = np.asarray(Image.open("6578747261637400.jpg").convert("L")).astype(int)
b = np.clip((255 - a) * 255.0 / (a.max() - a.min()), 0, 255).astype("uint8")
Image.fromarray(b).resize((a.shape[1] * 8, a.shape[0] * 8)).save("cipher_zoom.png")

a = 5 与 26 互质,模逆元为 215 * 21 = 105 ≡ 1 (mod 26))。解密公式是 D(y) = 21 * (y - 10) mod 26

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
def affine_decrypt(cipher, a=5, b=10, alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
a_inv = pow(a, -1, len(alphabet))
out = []
for ch in cipher:
if ch.upper() in alphabet:
x = alphabet.index(ch.upper())
out.append(alphabet[(a_inv * (x - b)) % len(alphabet)])
else:
out.append(ch)
return "".join(out)

def affine_encrypt(plain, a=5, b=10, alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
out = []
for ch in plain:
if ch.upper() in alphabet:
x = alphabet.index(ch.upper())
out.append(alphabet[(a * x + b) % len(alphabet)])
else:
out.append(ch)
return "".join(out)

ct = "PGNNZCFYXD"
pt = affine_decrypt(ct)
print(pt)
print(affine_encrypt(pt) == ct) # True:往返一致
bulldozinj

Challenge

Steganography

The only hint you get is The LaughingMan. 唯一提示是 The LaughingMan

题目提供一张 BMP 图片,密码藏在文件结构中。资源地址是 https://www.hackthissite.org/missions/stego/lvl/13.bmp

Solution

Analysis

先确认 BMP 的声明信息和实际文件长度:

1
2
3
$ file 13.bmp
13.bmp: PC bitmap, Windows 3.x format, 138 x 34 x 24, image size 14144,
cbSize 14198, bits offset 54

再解析 BMP 文件头。24bpp、宽度 138 的行长度按 4 字节对齐后为 416 字节:

1
2
3
4
5
6
7
8
file length       = 21002
bfSize = 14198
pixel offset = 54
width × height = 138 × 34
bits per pixel = 24
row bytes = 416
pixel bytes = 416 × 34 = 14144
expected end = 54 + 14144 = 14198

文件头声明的合法结束位置是 14198,但实际文件有 21002 字节;多出的 6804 字节并不是简单地集中在文件尾部,而是混入了文件流。

对整个文件搜索题目中反复出现的 ASCII 文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pathlib import Path

raw = Path("13.bmp").read_bytes()
junk = b"I thought what I'd do was, I'd pretend I was one of those deaf-mutes"

positions = []
start = 0
while True:
pos = raw.find(junk, start)
if pos < 0:
break
positions.append(pos)
start = pos + 1

print("file length:", len(raw))
print("junk length:", len(junk))
print("junk occurrences:", len(positions))
print("junk bytes:", len(junk) * len(positions))
1
2
3
4
file length: 21002
junk length: 69
junk occurrences: 56
junk bytes: 3864

这里的字符串中,I'dpretend 之间有两个空格;少一个空格就无法匹配实际数据。除了这 56 段文本,垃圾块后面还跟着一批 0x00 字节:

1
2
3
extra bytes       = 21002 - 14198 = 6804
repeated strings = 56 × 69 = 3864
zero padding = 6804 - 3864 = 2940

因此只删除 ASCII 文本还不够,残留的零填充仍会让像素行错位。清理逻辑是:遇到完整的垃圾字符串时跳过它,然后继续跳过紧随其后的 0x00 字节;其他字节按原顺序保留。

Step 1: 重建合法 BMP

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
#!/usr/bin/env python3
import struct
from pathlib import Path

source = Path("13.bmp").read_bytes()
junk = b"I thought what I'd do was, I'd pretend I was one of those deaf-mutes"

clean = bytearray()
pos = 0
junk_count = 0
zero_count = 0

while pos < len(source):
if source.startswith(junk, pos):
junk_count += 1
pos += len(junk)
while pos < len(source) and source[pos] == 0:
zero_count += 1
pos += 1
continue

clean.append(source[pos])
pos += 1

clean = bytes(clean)
Path("13_clean.bmp").write_bytes(clean)

_, declared_size, _, _, pixel_offset = struct.unpack_from(
"<2sIHHI", clean, 0
)
_, width, height, _, bpp, _, image_size = struct.unpack_from(
"<IiiHHII", clean, 14
)
row_bytes = ((width * (bpp // 8) + 3) // 4) * 4
expected_size = pixel_offset + row_bytes * abs(height)

print("junk blocks:", junk_count)
print("zero padding:", zero_count)
print("clean length:", len(clean))
print("declared size:", declared_size)
print("expected size:", expected_size)
print("image size:", image_size)
assert len(clean) == declared_size == expected_size
1
2
3
4
5
6
junk blocks: 56
zero padding: 2940
clean length: 14198
declared size: 14198
expected size: 14198
image size: 14144

清理后的文件重新满足 BMP 头部的全部长度约束。直接打开或放大 13_clean.bmp,即可看到隐藏文本:

1
2
3
4
$ convert 13_clean.bmp -resize 400% 13_clean.png
$ file 13_clean.bmp
13_clean.bmp: PC bitmap, Windows 3.x format, 138 x 34 x 24, image size 14144,
cbSize 14198, bits offset 54
acf42hvx10

Challenge

This is an encoded message, the only tip you get is 'I am the not of a file'.

Solution

Analysis

1
2
3
curl -sL -b "$HTS_COOKIE" -o 12.bmp \
'https://www.hackthissite.org/missions/stego/lvl/12.bmp'
file 12.bmp
1
2
12.bmp: PC bitmap, Windows 3.x format, 123 x 1 x 8, image size 126,
resolution 2834 x 2834 px/m, cbSize 1204, bits offset 1078

一张 123×1、只有 1 个像素高的 8 bpp(调色板) BMP,整个文件才 1204 字节。

先把 BMP 头字段算清楚:

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

d = open("12.bmp", "rb").read()
bf_type, bf_size, _, _, off = struct.unpack_from("<2sIHHI", d, 0)
hsz, w, h, planes, bpp, comp, img_size = struct.unpack_from("<IiiHHII", d, 14)
row = ((w * bpp + 31) // 32) * 4 # 8bpp 每行按 4 字节对齐
print("bfType", bf_type, "bfSize", bf_size, "dataOffset", off)
print("w", w, "h", h, "bpp", bpp, "biSizeImage", img_size)
print("row_bytes", row, "pixel_bytes", row * h)
print("palette_entries", (off - 14 - hsz) // 4)
print("trailing", len(d) - off - row * h)
1
2
3
4
5
bfType b'BM' bfSize 1204 dataOffset 1078
w 123 h 1 bpp 8 biSizeImage 126
row_bytes 124 pixel_bytes 124
palette_entries 256
trailing 2

dataOffset=1078 正好等于 14 + 40 + 1024, 即文件头 14 字节 + 信息头 40 字节 + 256 项调色板(每项 4 字节 = 1024)。 像素区是 124 × 1 = 124 字节(123 个像素 + 1 个对齐填充),像素之后只剩两个 0000 填充, 没有尾部附加文件。信息只能在像素值里。

确认这个调色板的内容:

1
2
3
4
5
6
7
import struct

d = open("12.bmp", "rb").read()
off = struct.unpack_from("<I", d, 10)[0]
pal = d[14 + 40:off]
gray = all(tuple(pal[i:i + 3]) == (i // 4, i // 4, i // 4) for i in range(0, len(pal), 4))
print("palette is grayscale identity:", gray)
1
palette is grayscale identity: True

调色板是恒等灰度斜坡:第 i 项就是 (i, i, i)。所以像素索引值等于它显示出来的灰度, 每字节一个像素,直接读原始字节即可。

题面 I am the not of a file 里的 not 就是按位取反:255 - x。 把 124 个像素字节逐个取反:

1
2
pixel bytes : af b4 fc fb eb ff ff ff ff ff 8b 7f 04 c7 a5 b5 ...
NOT (255-x): 50 4b 03 04 14 00 00 00 00 00 74 80 fb 38 5a 4a ...

取反后的头四个字节是 50 4b 03 04PK\x03\x04:一个 ZIP 归档的本地文件头。 原来这张图片的像素值,本身就是某个文件按位取反后的样子,正如提示所说: 它就是某个文件的 not

把取反结果当作 ZIP 打开:

1
2
3
4
5
6
7
8
9
10
11
12
13
import io
import struct
import zipfile

d = open("12.bmp", "rb").read()
off = struct.unpack_from("<I", d, 10)[0]
w, h, planes, bpp = struct.unpack_from("<iiHH", d, 18)
row = ((w * bpp + 31) // 32) * 4
px = d[off:off + row * h]
decoded = bytes(255 - b for b in px) # bitwise NOT
with zipfile.ZipFile(io.BytesIO(decoded)) as z:
print(z.namelist())
print(z.read("pass.txt").decode())
6ae4nt5TB

Challenge

Steganography — I am not Shakespeare!

题目图片里是一段关于 bacon(培根)的文字,要求从中找出密码。

Solution

题面那句 I am not Shakespeare! 是双关:字形用的是文字排版(图里每个字母有粗体非粗体两种形态),这就是 Bacon 密码需要的两种符号。

1
2
3
粗体字母   -> B
非粗体字母 -> A
按阅读顺序每 5 个字母一组 -> 查 Bacon 表(26 字母版)
nothere

Challenge

Download the song Here kiss me alone

Solution

Analysis

1
2
3
4
5
curl -sL -b "$HTS_COOKIE" -o Stego9.zip \
'https://www.hackthissite.org/missions/stego/lvl/Stego9.zip'
7z l Stego9.zip
7z x Stego9.zip
soxi ecstasy-atb-music.wav
1
2
3
Type = zip
Date Time Attr Size Compressed Name
2008-06-24 08:51:34 ....A 1784818 1675128 ecstasy-atb-music.wav
1
2
3
4
5
6
Channels       : 2
Sample Rate : 11025
Precision : 16-bit
Duration : 00:00:40.47 = 446190 samples
File Size : 1.78M
Sample Encoding: 16-bit Signed Integer PCM

ZIP 里只有一个 40 秒、11025 Hz、16 bit 立体声的 WAV。单看左右声道各自都是一首连续的 电子舞曲,波形和频谱都很正常,秘密只在混音层面才会显形。

Step 1: WAV chunks

先按 WAV 的块结构逐块核对尾部附加数据,以及头部声明长度与实际文件长度的一致性:

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

d = open("ecstasy-atb-music.wav", "rb").read()
print("riff", d[:4], "file_size", len(d))
pos = 12
while pos + 8 <= len(d):
cid = d[pos:pos + 4].decode("ascii", "replace")
size = struct.unpack_from("<I", d, pos + 4)[0]
print("%-4s offset=%d size=%d" % (cid, pos + 8, size))
pos += 8 + size + (size & 1) # 块按 2 字节对齐
print("end_of_chunks", pos, "== file size", pos == len(d))
1
2
3
4
5
riff b'RIFF' file_size 1784818
fmt offset=20 size=18
fact offset=46 size=4
data offset=58 size=1784760
end_of_chunks 1784818 == file size True

data 块正好覆盖到文件结尾(58 + 1784760 = 1784818),没有任何尾部附加数据; 头部声明的长度也和真实文件一致。信息只能在 PCM 采样里。

Step 2: Channel inversion

分别看左右声道的能量和相关性:

1
2
3
4
5
6
7
8
9
10
import wave
import numpy as np

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
L, R = pcm[0::2], pcm[1::2]
print("corr(L,R) = %.6f" % np.corrcoef(L, R)[0, 1])
print("std L=%.1f R=%.1f L-R=%.1f L+R=%.1f" % (
L.std(), R.std(), (L - R).std(), (L + R).std()))
1
2
corr(L,R) = -0.999697
std L=18641.6 R=18642.0 L-R=37280.8 L+R=458.9

这一步就是全部关键。两个声道的相关系数是 -0.9997:右声道是左声道的反相副本。 所以:

  • 现场听感正常,是因为播放器把两声道混合后音乐照旧;
  • L - R 的幅度几乎是被乘了 2 倍的歌曲本身(std 37281 ≈ 2×18641);
  • L + R 却塌缩成 std 只有 459 的极小残差。

音乐在求和时被抵消,留下的 L + R 才是承载信息的信号。把它单独导出成单声道:

1
2
3
4
5
6
7
8
9
10
11
12
13
import wave
import numpy as np

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr, nch, sw = w.getframerate(), w.getnchannels(), w.getsampwidth()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
residual = (pcm[0::2] + pcm[1::2]).astype(np.int16)
with wave.open("residual_sum.wav", "wb") as o:
o.setnchannels(1)
o.setsampwidth(sw)
o.setframerate(sr)
o.writeframes(residual.tobytes())
print("residual std=%.1f absmax=%d" % (residual.std(), np.abs(residual).max()))
1
residual std=458.9 absmax=1044

这个残差和歌曲本身几乎不相关(corr(L+R, L) = 0.0115),是一个独立的信号。

Step 3: Carrier

对残差做精细 FFT,看能量集中在哪:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import wave
import numpy as np

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
res = pcm[0::2] + pcm[1::2]

chunk = res[100000:100000 + 8192] * np.hanning(8192)
spec = np.abs(np.fft.rfft(chunk))
freqs = np.fft.rfftfreq(8192, 1.0 / sr)
band = (freqs > 500) & (freqs < 2000)
top = band.nonzero()[0][np.argsort(spec[band])[-5:]][::-1]
for i in top:
print("%7.1f Hz mag %8.1f" % (freqs[i], spec[i]))
1
2
3
4
5
 998.6 Hz  mag 963327.3
999.9 Hz mag 917071.2
1002.6 Hz mag 903649.9
1001.3 Hz mag 665056.8
1004.0 Hz mag 341981.2

残差里几乎只有一个 约 1000 Hz 的纯载波(相邻几条谱线都挤在 1 kHz 附近, 是同一根载波在 FFT 分辨率上的展宽)。再用时间轴看它,按 100 ms 一格量载波的能量, 会发现它一段一段地通断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import wave
import numpy as np
from scipy.signal import butter, hilbert, sosfiltfilt

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
res = pcm[0::2] + pcm[1::2]

sos = butter(4, [800 / (sr / 2), 1200 / (sr / 2)], btype="band", output="sos")
env = np.abs(hilbert(sosfiltfilt(sos, res)))
env = np.convolve(env, np.ones(32) / 32, mode="same")
step = sr // 10
print(" ".join("%4d" % env[i * step:(i + 1) * step].mean()
for i in range(30)))
1
517  249 1034  781    4 1019 1027   12  747 1017  271  495 1035  482    0    1  997 1006   23  708  994  283  394  856  513  222 1033  676    1  947

每 100 ms 的能量在接近 1000 和接近 0 两档之间来回跳,这正是通断键控(OOK), 把通断的时间长度读出来就是莫尔斯电码。

Step 4: Morse

对上一步的包络取阈值、做游程编码,按运行长度区分点 / 划和字符内间隔 / 字符间间隔 / 词间间隔,然后查表:

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
import wave
import numpy as np
from scipy.signal import butter, hilbert, sosfiltfilt

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
res = pcm[0::2] + pcm[1::2]

sos = butter(4, [800 / (sr / 2), 1200 / (sr / 2)], btype="band", output="sos")
env = np.abs(hilbert(sosfiltfilt(sos, res)))
env = np.convolve(env, np.ones(32) / 32, mode="same")
on = env > env.max() * 0.25
first, last = np.flatnonzero(on)[[0, -1]]
on = on[first:last + 1]

runs = []
cur, start = bool(on[0]), 0
for i in range(1, len(on)):
if bool(on[i]) != cur:
runs.append((cur, i - start))
cur, start = bool(on[i]), i
runs.append((cur, len(on) - start))

ms = lambda n: round(n / sr * 1000)
print("on ms:", [ms(n) for c, n in runs if c][:24])
print("off ms:", [ms(n) for c, n in runs if not c][:24])
1
2
on  ms: [51, 201, 202, 202, 201, 202, 202, 202, 201, 202, 201, 201, 52, 50, 52, 52, 51, 52, 51, 51, 202, 52, 51, 52]
off ms: [124, 124, 124, 124, 249, 124, 124, 124, 124, 249, 124, 124, 124, 125, 499, 124, 124, 124, 124, 249, 124, 124, 124, 124]

时长分成非常干净的四档:

  • 通 ≈ 51 ms(点)与 ≈ 201 ms(划),约 1:4;
  • 断 ≈ 124 ms(字符内部间隔)、≈ 249 ms(字符之间)、≈ 499 ms(词之间),约 1:2:4。

按这个比例把游程翻译成 ./-,用 124/249/499 三档间隔切分字符和词:

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
import wave
import numpy as np
from scipy.signal import butter, hilbert, sosfiltfilt

MORSE = {
"-----": "0", ".----": "1", "..---": "2", "...--": "3", "....-": "4",
".....": "5", "-....": "6", "--...": "7", "---..": "8", "----.": "9",
}

with wave.open("ecstasy-atb-music.wav", "rb") as w:
sr = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(np.float64)
res = pcm[0::2] + pcm[1::2]

sos = butter(4, [800 / (sr / 2), 1200 / (sr / 2)], btype="band", output="sos")
env = np.abs(hilbert(sosfiltfilt(sos, res)))
env = np.convolve(env, np.ones(32) / 32, mode="same")
on = env > env.max() * 0.25
first, last = np.flatnonzero(on)[[0, -1]]
on = on[first:last + 1]

runs = []
cur, start = bool(on[0]), 0
for i in range(1, len(on)):
if bool(on[i]) != cur:
runs.append((cur, i - start))
cur, start = bool(on[i]), i
runs.append((cur, len(on) - start))

unit = min(n for c, n in runs if c) # 最短的通段 = 一个点
words, word, sym = [], [], ""
for is_on, n in runs:
if is_on:
sym += "." if n < unit * 2.5 else "-"
elif n < unit * 4: # 字符内间隔,忽略
continue
elif n < unit * 7: # 字符间间隔
word.append(sym)
sym = ""
else: # 词间间隔
word.append(sym)
words.append(word)
word, sym = [], ""
if sym:
word.append(sym)
if word:
words.append(word)

digits = ["".join(MORSE[s] for s in w) for w in words]
print("morse digits:", " ".join(digits))
print("ascii :", " ".join(chr(int(d)) for d in digits))
k8x0w5bbuq

Challenge

Thank you t_n83

Solution

Analysis

1
2
3
curl -sL -b "$HTS_COOKIE" -o stego8.bmp \
'https://www.hackthissite.org/missions/stego/lvl/stego8.bmp'
file stego8.bmp
1
2
stego8.bmp: PC bitmap, Windows 3.x format, 157 x 97 x 24, image size 45784,
cbSize 45838, bits offset 54

一张 157×97 的 24 位 BMP,体积很小。直接打开只看到一幅白底图上印着一行字和一条下划线。

BMP 的隐写常见路径只有几条:尾部附加数据、头部声明尺寸和真实长度不符、调色板里的垃圾项。 先排除这几条常见路径:

1
2
3
4
5
6
7
8
9
10
import struct

d = open("stego8.bmp", "rb").read()
bf_type, bf_size, _, _, off = struct.unpack_from("<2sIHHI", d, 0)
hsz, w, h, _, bpp, comp, img_size = struct.unpack_from("<IiiHHII", d, 14)
row = ((w * 3 + 3) // 4) * 4 # 24bpp 每行按 4 字节对齐
print("bfType", bf_type, "bfSize", bf_size, "dataOffset", off)
print("w", w, "h", h, "bpp", bpp, "biSizeImage", img_size)
print("row_bytes", row, "pixel_bytes", row * h)
print("trailing", len(d) - off - row * h)
1
2
3
4
bfType b'BM' bfSize 45838 dataOffset 54
w 157 h 97 bpp 24 biSizeImage 45784
row_bytes 472 pixel_bytes 45784
trailing 0

像素区正好 472 × 97 = 45784 字节,文件在像素之后没有任何多余数据(trailing=0); 24 bpp 的 BMP 没有调色板,palette 这条线也不用查。信息只能在像素值里。

分别统计 R、G、B 三个通道的取值分布:

1
2
3
4
5
6
7
8
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB"))
for i, name in enumerate("RGB"):
vals, counts = np.unique(a[:, :, i], return_counts=True)
top = sorted(zip(counts.tolist(), vals.tolist()), reverse=True)[:4]
print(name, "uniq =", len(vals), "top =", top)
1
2
3
R uniq = 26 top = [(14849, 255), (293, 0), (20, 240), (15, 124)]
G uniq = 14 top = [(14849, 255), (308, 0), (20, 240), (15, 124)]
B uniq = 14 top = [(14849, 255), (308, 0), (20, 240), (15, 124)]

图像呈灰度外观,但通道统计显示:R 通道比 G、B 多出一批取值。进一步比对三个通道:

1
2
3
4
5
6
7
8
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
print("R!=G:", int((r != g).sum()), "G!=B:", int((g != b).sum()))
ys, xs = np.where(r != g)
print("bbox x=[%d..%d] y=[%d..%d]" % (xs.min(), xs.max(), ys.min(), ys.max()))
1
2
R!=G: 15 G!=B: 0
bbox x=[68..82] y=[50..50]

绝大多数像素满足 R == G == B(真正的灰度),只有 15 个像素例外,而且全部 集中在同一行 y=50x6882 的一段连续像素上。这 15 个点就是全部秘密。

这 15 个像素的形态是 (R, 0, 0):绿、蓝被清零,红通道独自保留一个值。 把这一行原样打印出来:

1
2
3
4
5
6
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
for x in range(66, 85):
print(x, tuple(a[50, x].tolist()))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
66 (255, 255, 255)
67 (255, 255, 255)
68 (112, 0, 0)
69 (97, 0, 0)
70 (115, 0, 0)
71 (115, 0, 0)
72 (119, 0, 0)
73 (111, 0, 0)
74 (114, 0, 0)
75 (100, 0, 0)
76 (61, 0, 0)
77 (89, 0, 0)
78 (114, 0, 0)
79 (82, 0, 0)
80 (111, 0, 0)
81 (116, 0, 0)
82 (55, 0, 0)
83 (255, 255, 255)
84 (255, 255, 255)

这些值不构成图案,它们本身就是字节

一串代码把上面几步串起来即可,提取结果:

1
2
3
4
5
6
7
8
9
10
11
import numpy as np
from PIL import Image

a = np.array(Image.open("stego8.bmp").convert("RGB")).astype(int)
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
ys, xs = np.where((r != g) & (g == b)) # 红独有像素 = 载体
order = np.argsort(xs)
xs, ys = xs[order], ys[order]
text = "".join(chr(int(a[y, x, 0])) for x, y in zip(xs.tolist(), ys.tolist()))
print(text)
print(text.split("=", 1)[1]) # 取等号后的答案
YrRot7

Challenge

Steganography

关卡页只给出 Download the image Here,下载链接指向一个 ZIP 文件: https://www.hackthissite.org/missions/stego/lvl/stego7.zip

The image contains the password in one of its Photoshop layers.

Solution

Analysis

先下载并检查外层 ZIP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ curl -sL -b "$HTS_COOKIE" \
-o stego7.zip \
'https://www.hackthissite.org/missions/stego/lvl/stego7.zip'
$ unzip -l stego7.zip
Archive: stego7.zip
Length Date Time Name
--------- ---------- ----- ----
69392 2008-04-27 01:36 stego7.tif
--------- ---------- ----- ----
69392 1 file
$ unzip -t stego7.zip
testing: stego7.tif OK
No errors detected in compressed data of stego7.zip.
$ unzip -o stego7.zip

ZIP 没有加密,里面只有一个 stego7.tif。外层 ZIP 只是把真正的载体再包了一层,直接对 ZIP 本身做图片隐写分析不会得到结果。

Step 1: 确认 TIFF 的图层信息

1
2
3
4
$ file stego7.tif
stego7.tif: TIFF image data, little-endian, direntries=22, width=175, \
height=36, bps=278, compression=none, PhotometricInterpretation=RGB, \
orientation=upper-left

普通 TIFF 信息只能看到一张 175×36 的 RGB 图;tiffinfo 也只列出一个正常的 TIFF directory。继续查看 Photoshop 私有数据块:

1
2
3
4
5
6
7
8
9
10
11
$ exiftool -a -G1 -s stego7.tif | grep -E \
'Layer|Software|ImageWidth|ImageHeight|BitsPerSample|Compression'
[IFD0] ImageWidth : 175
[IFD0] ImageHeight : 36
[IFD0] BitsPerSample : 8 8 8
[IFD0] Compression : Uncompressed
[IFD0] Software : Adobe Photoshop CS2 Windows
[Photoshop] LayerCount : 3
[Photoshop] LayerRectangles : 0 0 36 175, 9 0 36 175, 0 0 35 175
[Photoshop] LayerNames : Layer 2, Layer 0, Layer 1
[Photoshop] LayerVisible : Yes, Yes, Yes

关键点是:图层不在普通 TIFF directory 中,而是存放在 Photoshop 的 Image Resource 数据里。普通图片查看器只显示合成结果,密码所在的图层/alpha 通道会被背景干扰。

Step 2: 导出图层的 alpha 通道

ImageMagick 会把 Photoshop 图层作为额外的 TIFF scene 导出。逐个取 alpha 通道并放大:

1
2
3
4
$ for n in 0 1 2 3; do
convert "stego7.tif[$n]" -alpha extract -resize 1000% \
"alpha-$n.png"
done

前几个 scene 主要是合成图或空白/干扰内容;最后一个 alpha 图中出现清晰的点阵字形。可以直接目视读取

Key points

这关的载荷不在 ZIP 的压缩数据、TIFF 的普通像素或常规 EXIF 字段里,而在 Photoshop 保存的图层数据中。排查顺序应是:

  1. 解开外层 ZIP,确认真正的载体;
  2. exiftool 检查 Photoshop 图层元数据;
  3. 导出各图层及其 alpha 通道;
4aH5CEta

Challenge

A PNG image is provided. The only hint is Thank you Leafman. 题目提供了一张 PNG 图片,唯一的提示是 Thank you Leafman(感谢 Leafman)。

数据文件在 https://www.hackthissite.org/missions/stego/lvl/stego6.png,是一个 100x100 的 RGBA PNG。

Solution

Step 1: Container

file 只认出这是 100x100 的 8-bit RGBA PNG,共 1965 字节:

1
2
3
4
5
6
$ file stego6.png
stego6.png: PNG image data, 100 x 100, 8-bit/color RGBA, non-interlaced
$ wc -c stego6.png
1965 stego6.png
$ sha256sum stego6.png
207ef63dfb3d052e347d0b91c9ad9523f8e8212505013030abdbdbd9ff8fa751 stego6.png

PNG 是块(chunk)结构,先用 pngcheck -v 走一遍每个块:

1
2
3
4
5
6
7
8
9
10
11
12
$ pngcheck -v stego6.png
File: stego6.png (1965 bytes)
chunk IHDR at offset 0x0000c, length 13
100 x 100 image, 32-bit RGB+alpha, non-interlaced
chunk bKGD at offset 0x00025, length 6
red = 0x00ff, green = 0x00ff, blue = 0x00ff
chunk tIME at offset 0x00037, length 7: 12 Nov 2007 13:51:36 UTC
chunk IDAT at offset 0x0004a, length 1779
zlib: deflated, 32K window, default compression
chunk IEND at offset 0x00749, length 0
additional data after IEND chunk
ERRORS DETECTED in stego6.png

结构很干净:IHDR、一个背景色块 bKGD、一个时间戳块 tIME、一个 IDAT、IEND。bKGDtIME 都是普通元数据,没有藏东西。唯一的异常是最后一行:IEND 之后还有数据

IEND 是 PNG 的结束标记,规范上文件到它就结束。这里 IEND 块结束于偏移 0x751,而文件长 0x7AD,即后面多出 92 字节。pngcheck 把声明结束点之后还有字节直接报告成 ERRORS DETECTED。

Step 2: Pixel analysis

PNG 隐写的另一条常见路径是把数据写入 IDAT 解码后的像素位平面(LSB)。用 zsteg -a 扫全部通道、全部位平面:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ zsteg -a stego6.png | tr '\r' '\n' | sed '/^[[:space:]]*$/d' | head -n 12
extradata:0 .. text: "Tm90IGxpa2UgaXQncyBoYXJkIHRvICdkZWNyeXB0JyB0aGlzIGh1aD8gVGhlIHBhc3N3b3JkIGlzIGhnYnZadzA3Lg=="
chunk:0:IHDR .. file: Adobe Photoshop Color swatch, version 0, 100 colors; 1st RGB space (0), w 0x64, x 0x806, y 0, z 0; 2nd RGB space (0), w 0, x 0, y 0, z 0
b1,r,lsb,xy ..
b1,r,msb,xy ..
b1,g,lsb,xy ..
b1,g,msb,xy ..
b1,b,lsb,xy ..
b1,b,msb,xy ..
b1,a,lsb,xy ..
b1,a,msb,xy ..
b1,rgb,lsb,xy ..
b1,rgb,msb,xy ..

zsteg -a 的原始输出用 \r 分隔通道,这里用 tr 归一化成每行一个通道再看。)

所有 bN,channel,lsb/msb 位平面要么为空,要么只是 text: ["U" repeated 21 times] 这类单字符重复的噪声(那是图像本身大色块的规律,不是文本),没有一处读出可读字符串。第 2 行 chunk:0:IHDR 是 zsteg 把 IHDR 头字节误认成 Photoshop 色板,与本题无关。zsteg 唯一有效的一行是 extradata:0IEND 之后的 92 字节额外数据,内容是 Base64 文本。

Step 3: Payload

定位 IEND 块结束的位置,取出后面的字节:

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
#!/usr/bin/env python3
"""HackThisSite Steganography 6 - extract the appended payload from stego6.png.

The PNG container is normal (IHDR / bKGD / tIME / IDAT / IEND). After the IEND
chunk there are 92 extra bytes that are not part of the image stream: an ASCII
Base64 string. Decoding it yields a sentence that carries the password.
"""
import base64
import sys


def extract(path):
data = open(path, "rb").read()
i = data.find(b"IEND") # IEND is unique in a valid PNG
end = i + 8 # chunk length(4) + type(4) = end of IEND
trailer = data[end:].strip() # everything after the PNG stream
message = base64.b64decode(trailer).decode("ascii")
return end, trailer.decode("ascii"), message


def main():
path = sys.argv[1] if len(sys.argv) > 1 else "stego6.png"
end, b64, message = extract(path)
print("IEND ends at offset:", hex(end))
print("trailing bytes :", len(b64))
print("Base64 :", b64)
print("decoded message :", message)


if __name__ == "__main__":
main()
hgbvZw07

Challenge

Steganography

题目给一张 BMP(/missions/stego/lvl/stego5.bmp),密码藏在像素数据里。

Solution

这关的手法是把像素值当数据(与改扩展名/尾部追加那类做法不同): 把 BMP 转成十六进制转储后,可以看到像素三元组(每 3 个十六进制字节 = 一个像素的 RGB)里 存在有规律的偏差。某些分量比邻近像素略高或略低,这些偏差就是被编码的比特。

1
2
3e 3f 3f   4e 4f 4f   42 43 42   3b 3b 0a
0 1 1 0 1 1 0 1 0 1 1 0

把这类偏差按顺序抽出来拼成位流,再按 8 位一组转 ASCII

Key points

把信息藏进像素的低位/微差是最经典的隐写:对肉眼和普通查看器完全不可见, 但只要把图像当作字节数组而不是图片来读,规律就立刻暴露。 它只能对抗只看图不看数据的处理流程。

syn-ack-rst

Challenge

A GIF image is provided. The only hint is I am being hexed!. 题目提供了一张 GIF 图片,唯一的提示是 I am being hexed!(我被十六进制了)。

Solution

file 只按魔数认出这是 GIF89a,175x36,看不出别的:

1
2
3
4
$ file stego4.gif
stego4.gif: GIF image data, version 89a, 175 x 36
$ wc -c stego4.gif
4703 stego4.gif

提示指向十六进制,于是直接 xxd 看尾部。GIF 的 trailer 是单字节 0x3b;),标准文件到它就结束了:

1
2
3
4
5
00001210: aeda 6c01 0df0 4ef2 2002 b713 0400 3b30  ..l...N. .....;0
00001220: 3131 3130 3030 3030 3031 3130 3131 3030 1110000001101100
00001230: 3031 3131 3030 3030 3131 3030 3031 3130 0111000011000110
00001240: 3131 3130 3030 3130 3031 3130 3030 3130 1110001001100010
00001250: 3131 3031 3030 3030 3131 3030 3031 30 110100001100010

trailer 后面跟着一串 ASCII 字符 '0''1'(字节值 0x30/0x31)。0x3b 直接收尾的话,文件在 0x121e 就该结束了,实际还有 64 字节。

光看尾部不够,先确认整张图没有别的附载(注释扩展、应用扩展、调色板里塞数据)。按 GIF 规范从 header 往后解析每个块:

1
2
3
4
5
6
header: GIF89a
LSD: 175x36 packed=0xd5 gct=True gct_entries=64 bg=0 aspect=0
@0x00d5 EXT GraphicControl (0xf9) subblocks=1 len=4 preview=b'\x00\x00\x00\x00'
@0x00df IMAGE_DESC at (0,0) 175x36 packed=0x00 lct=False lct_entries=0 interlace=False
LZW_min=6 data_len=4395
@0x121e TRAILER 0x3B; remaining bytes after = 64

结构干净:一个 GCT(64 项)、一个 Graphic Control Extension、一个 Image Descriptor 加 LZW 数据,之后就是 0x3B trailer。没有 Comment 或 Application Extension,附载只有 trailer 之后那 64 字节。

要注意 0x3B 这个字节值在 LZW 图像数据里也会自然出现,本文件里一共出现 23 次,0x121e 是最后一次。所以定位 trailer 不能取第一个 0x3B,要么按块走到 trailer,要么取最靠后的那一个。

trailer 之后的 64 个 '0'/'1' 字符每 8 位一组拼成字节:

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
#!/usr/bin/env python3
"""HackThisSite Steganography 4 ("I am being hexed!") — extract the appended payload.

The GIF is a single-frame GIF89a image. After the normal image data it carries a
standard GIF trailer byte 0x3B, and then 64 extra bytes that are not part of the
image stream: the ASCII characters '0' and '1'. Grouped into bytes they spell an
8-character lowercase password.
"""
import sys

def extract(path):
data = open(path, "rb").read()
trailer = data.rfind(b"\x3b") # last GIF trailer byte (0x3B = ';')
trailing = data[trailer + 1:] # everything after the trailer
if not set(trailing) <= set(b"01"):
raise SystemExit("trailing data is not a '0'/'1' bit string: %r" % trailing[:32])
bits = trailing.decode("ascii")
if len(bits) % 8:
raise SystemExit("bit string length %d is not a multiple of 8" % len(bits))
out = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits), 8))
return trailer, bits, out

def main():
path = sys.argv[1] if len(sys.argv) > 1 else "stego4.gif"
trailer, bits, out = extract(path)
print("trailer 0x3B at offset:", hex(trailer))
print("trailing bit string (%d bits):" % len(bits), bits)
print("decoded bytes (hex):", out.hex())
print("decoded ASCII :", out.decode("ascii"))

if __name__ == "__main__":
main()
p68cq1hb