CodeShell.kr - Fried Egg

Challenge

Round 1 Winner 题,题面只有一句「世界上不该只有一个太阳」。图片 LSB 里是一个自制容器格式,真载荷指向站点 footer 的一个链接,答案因此只到候选强度。

You have worked hard to make it this far. But should there not be only one sun in the world?

你已经很努力走到这里了。但世界上不应该只有一个太阳吗?

1
https://codeshell.kr/challenges/fried-egg/

Solution

Step 1:两张图的 RGB LSB 平面都带同一个容器格式:

1
"CSSTEG2" || version(1B) || BE32(length) || payload

题面配图 fried-egg-pan.png 里是真载荷;附件 fried-egg-stego.png 里是诱饵, payload 就 10 个字节:

1
FLAG{FAKE}

Step 2:真载荷是 JSON:

1
{"image_b64": "/9j/4AAQSkZJRg...", "message": "..."}

image_b64 解出 200×250 的韩文专辑封面;message 是 ASCII 提示:

1
https://codeshell.kr/   ->  footer 3rd button click -> music title -> FLAG

Step 3:站点 footer 只有 3 个链接,顺序即 HTML 顺序(页面无 JS,CSS 也没反转):

1
1 discord    2 linkedin    3 youtube -> https://www.youtube.com/watch?v=uiz7EsMPsuw

第 3 个按钮指向的视频,标题由 yt-dlp 与 YouTube oEmbed 两个来源核验一致:

1
NewJeans (뉴진스) - GODS | Worlds 2023 Finals Opening Ceremony Presented by Mastercard

描述为 "the live performance of GODS by NewJeans",缩略图印着 GODS FEAT. NEWJEANS音乐标题 = GODS

1
2
$ uv run python solvers/friedegg_scan.py   # 定位容器
$ uv run python solvers/friedegg.py # 解出 JPEG + message

未定项:内嵌的韩文封面与视频缩略图完全不同,不是同一素材。它的竖排标题经行投影 客观计数为 6 个笔画带,其中两个短带(高 11/12 px)应属同一音节的分离部件,合并后 5 个音节且有一处 10 px 词边界(结构 3+2),与韩文专辑《하늘과 바다》吻合;但该图 只有 200×250(标题区 48×150 px),视觉模型对同一区域给出四种互不相同的读数,无法判定, 本机 tesseract 也没有韩文语言包。结合题面"世界上不应该只有一个太阳吗",最合理的解释是 这张封面是第二个"太阳"(诱饵音乐标题),真实路线只有 footer 那条。

Script

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
#!/usr/bin/env python3
"""CodeShell.kr — fried-egg: locate the CSSTEG2 payload(s).

zsteg showed the RGB-LSB plane of fried-egg-stego.png starting with the magic
`CSSTEG2` followed by `FLAG{FAKE}` -- a decoy. The hint ("should there not be
only one sun in the world?") points at the second image, fried-egg-pan.png.

Scan both images across channel/bit/order combinations for the CSSTEG2 magic and
dump whatever follows it.
"""
import sys
from pathlib import Path

import numpy as np
from PIL import Image

MAGIC = b'CSSTEG2'
ROOT = Path('codeshell')
IMAGES = [
ROOT / 'assets/challenge-files/fried-egg-stego.png',
ROOT / 'assets/challenge-images/fried-egg-pan.png',
]


def planes(a):
"""Yield (label, bitarray) for every channel/bit/order combination."""
h, w, _ = a.shape
chans = {'rgb': a, 'r': a[:, :, 0:1], 'g': a[:, :, 1:2], 'b': a[:, :, 2:3],
'bgr': a[:, :, ::-1]}
for cname, ch in chans.items():
for bit in range(8):
bits = (ch >> bit) & 1
flat_row = bits.reshape(-1).astype(np.uint8)
yield f"{cname}|bit{bit}|rowmajor", flat_row
yield f"{cname}|bit{bit}|colmajor", bits.transpose(1, 0, 2).reshape(-1).astype(np.uint8)


def scan(path):
a = np.array(Image.open(path).convert('RGB'))
print(f"--- {path} {a.shape}")
hits = 0
for label, bits in planes(a):
packed = np.packbits(bits).tobytes()
pos = packed.find(MAGIC)
if pos < 0:
continue
hits += 1
tail = packed[pos:pos + 120]
print(f" {label:26s} offset {pos:8d} {tail!r}")
if not hits:
print(" no CSSTEG2 magic found")


for p in IMAGES:
scan(p)
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
#!/usr/bin/env python3
"""CodeShell.kr — Fried Egg (Stegano, 6400p) solver.

Both images carry the challenge's own container format in the RGB-LSB plane:

"CSSTEG2" || BE32(payload_length) || payload

* fried-egg-stego.png : length 0, followed by the decoy text "FLAG{FAKE}".
* fried-egg-pan.png : length 14305, payload is JSON with a base64 JPEG.

The hint "should there not be only one sun in the world?" is the giveaway: the
stego image is the fake sun, the pan image holds the real one. Decoding the
embedded JPEG yields the actual flag.
"""
import base64
import hashlib
import json
import sys
from pathlib import Path

import numpy as np
from PIL import Image

ROOT = Path('codeshell')
MAGIC = b'CSSTEG2'
OUT = ROOT / 'extracted/fried-egg'


def lsb_bytes(path):
a = np.array(Image.open(path).convert('RGB'))
return np.packbits((a & 1).reshape(-1).astype(np.uint8)).tobytes()


def container(blob):
"""magic(7) || version(1) || BE32(payload_length) || payload."""
assert blob.startswith(MAGIC), "magic missing"
ver = blob[len(MAGIC)]
off = len(MAGIC) + 1
n = int.from_bytes(blob[off:off + 4], 'big')
return ver, n, blob[off + 4:off + 4 + n]


def main():
OUT.mkdir(parents=True, exist_ok=True)
for name in ('fried-egg-stego.png', 'fried-egg-pan.png'):
for sub in ('challenge-files', 'challenge-images'):
p = ROOT / 'assets' / sub / name
if not p.exists():
continue
ver, n, payload = container(lsb_bytes(p))
print(f"{name}: version {ver}, declared length {n}, "
f"payload {len(payload)} bytes")
print(f" head {payload[:64]!r}")
if not payload.startswith(b'{'):
print(" not JSON -- decoy payload, skipping")
continue
meta = json.loads(payload.decode())
print(f" json keys {list(meta)}")
for key, val in meta.items():
if not isinstance(val, str):
continue
try:
raw = base64.b64decode(val, validate=True)
except Exception: # noqa: BLE001
continue
dest = OUT / f"{name}.{key}.bin"
dest.write_bytes(raw)
print(f" wrote {dest} ({len(raw)} bytes, magic {raw[:4]!r})")


if __name__ == '__main__':
main()