最近一段时间准备复现一下nepctf 2026中misc方向的题目,相关的wp我将发在这篇博客里面

CatFlag

这一题也是纯纯签到题,不需要多说,就是这个Sixel格式第一次听,了解了一下,个人感觉是跟ASCII画图的原理差不多

image-20260809123824305

签到题也是很照顾我这种菜鸟,直接把命令都告诉我了

image-20260809124057955

1
NepCTF{Lets_Enjoy_NepCTF2026!Have_Fun!}

CatFlagExpert

1
2
3
4
5
签上到了?

你再找找看,第 2 个 flag 在哪里(附件同 CatFlag)

没签上也没关系,直接做这个,就能找到签到 flag 了 :)

这题是签到题的plus版,甚至连附件都是同一个

Part 1

自己直接sixel2png命令导出或者用找个sixel转图片的网站导出这张图,导出来的时候你是看不出什么不一样的

但是你放Stegsolve里面逐个通道查看时你就会看到图片的左上角藏着东西

image-20260809124825900

尝试单独提取各个通道的数字等各种方法无果,捣鼓了半个晚上,我还去问出题人,出题人说我的一个参数错了。但我仔细思考后觉得Stegsolve不应该是已经搞好的默认参数吗,哪来的什么调错了参数

后来我把red1那个通道的图片单独导出,放大后从屏幕侧边看还真就看出来了

image-20260809125248885

后面我发现还可以截图那一小部分后缩小离远了看,挺考验眼力的,嗯对。出题人防ai把人给防住了😂

1
NepCTF{Hell0_

Part 2

sixel 图片像素可被覆盖,把重复那部分单独看可以看到一个二维码

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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#!/usr/bin/env python3
import sys, re
from collections import defaultdict

try:
from PIL import Image
except ImportError:
sys.exit("[!] 需要 Pillow: pip install pillow")

def parse_sixel(raw: str):
m = re.search(r'\x1bPq(.+?)\x1b\\', raw, re.DOTALL) \
or re.search(r'\x1bP(.+?)\x1b\\', raw, re.DOTALL)
payload = m.group(1) if m else raw
payload = re.sub(r'^P?q?', '', payload, count=1) if not m else payload

x, y, cur_color = 0, 0, 0
pixel_map = defaultdict(list)

i, n = 0, len(payload)
while i < n:
ch = payload[i]

pm = re.match(r'"(\d+);(\d+);(\d+);(\d+)', payload[i:])
if pm:
i += len(pm.group(0)); continue

pm = re.match(r'#\d+;\d+;\d+;\d+;\d+', payload[i:])
if pm:
i += len(pm.group(0)); continue

pm = re.match(r'#(\d+)', payload[i:])
if pm:
cur_color = int(pm.group(1)); i += len(pm.group(0)); continue

if ch == '!':
pm = re.match(r'!(\d+)(.)', payload[i:])
if pm:
cnt, sch = int(pm.group(1)), pm.group(2)
for _ in range(cnt):
pixel_map[(x, y)].append((cur_color, sch)); x += 1
i += len(pm.group(0)); continue
i += 1; continue

if ch == '$':
x = 0; i += 1; continue
if ch == '-':
x = 0; y += 6; i += 1; continue

if 63 <= ord(ch) <= 126:
pixel_map[(x, y)].append((cur_color, ch)); x += 1; i += 1; continue

i += 1

return pixel_map

def sixel_to_bits(ch):
v = ord(ch) - 63
return [(v >> b) & 1 for b in range(6)]

def render_layer(pixel_map, layer_index, only_repeated=True):
targets = {pos: layers for pos, layers in pixel_map.items()
if (not only_repeated) or len(layers) > 1}
if not targets:
return None
max_col = max(p[0] for p in targets)
max_row = max(p[1] for p in targets) + 6
bmp = [[0] * (max_col + 1) for _ in range(max_row)]
for (cx, cy), layers in targets.items():
if layer_index >= len(layers):
continue
_, sch = layers[layer_index]
for b, bit in enumerate(sixel_to_bits(sch)):
if bit:
bmp[cy + b][cx] = 1
return bmp

def save_png(bmp, path, scale=4):
if not bmp:
print(f"[!] 空位图,跳过 {path}"); return
h, w = len(bmp), len(bmp[0])
img = Image.new('L', (w * scale, h * scale), 255)
px = img.load()
for r in range(h):
for c in range(w):
if bmp[r][c]:
for dr in range(scale):
for dc in range(scale):
px[c*scale+dc, r*scale+dr] = 0
img.save(path)
print(f"[+] 已保存 {path} (逻辑 {w}x{h}, 实际 {w*scale}x{h*scale})")

def ascii_preview(bmp, max_rows=60):
rows = bmp[:max_rows]
return '\n'.join(''.join('█' if c else ' ' for c in row) for row in rows)

def main():
path = sys.argv[1] if len(sys.argv) > 1 else 'flag.txt'
with open(path, encoding='utf-8', errors='replace') as f:
raw = f.read()

pmap = parse_sixel(raw)
repeated = {p: v for p, v in pmap.items() if len(v) > 1}

print(f"[*] 文件: {path}")
print(f"[*] 像素坐标总数: {len(pmap)}")
print(f"[*] 被重复绘制(覆盖)坐标数: {len(repeated)}")
if not repeated:
sys.exit("[!] 未发现重复像素点")

max_layer = max(len(v) for v in repeated.values())
print(f"[*] 最大覆盖层数: {max_layer}")
for idx in range(max_layer):
chars = sorted({repeated[p][idx][1] for p in repeated if idx < len(repeated[p])})
print(f" layer{idx}: {len(chars)} 种 -> {''.join(chars)[:60]}")

top_idx = max_layer - 1

print("\n=== [1] 重复点 · 最上层 -> 二维码 ===")
save_png(render_layer(pmap, top_idx, only_repeated=True), 'repeated_top.png')

print("\n=== [2] 重复点 · 首次写入层(被盖住) -> Malbolge 位图 ===")
bmp_hid = render_layer(pmap, 0, only_repeated=True)
save_png(bmp_hid, 'repeated_hidden.png')
print("\n--- 隐藏层 ASCII 预览(前60行) ---")
print(ascii_preview(bmp_hid))

code = ''.join(repeated[(cx, cy)][0][1]
for cx, cy in sorted(repeated, key=lambda p: (p[1], p[0])))
print(f"\n[+] 隐藏层 sixel 字符序列 (len={len(code)}):\n{code}")

print("\n=== [3] 整张图 · 最上层 (对照) ===")
save_png(render_layer(pmap, top_idx, only_repeated=False), 'full_top.png'

if __name__ == '__main__':
main()

打开repeated_hidden.png可以看到中间有个二维码

repeated_hidden

扫描二维码得到Malbolge 代码

1
'&%$#"!~}|{zyxwvutsrqponmlkjihgfedcba`_^]\[ZYXWVUTSRQPlNdibaf_dcbaZ~^]\>ZYRv9876543OHGFKJIBG@d'=B;:?87[5:9876/Stsrqponmlkjihgfedcba`_^]\[wYutsrqpohgf,MLKJIH^c\[`_^]V[Tx;QPUTMqQPON0FjDCg*)('&%A#?87<;:981Uvutsrqponmlkjihgfe#"!~}|{zyxwp6WVUTSRnPfkjibgfe^$\a`Y}j

去找个网站运行一下这代码,我用的是下面这个

1
https://www.dcode.fr/malbolge-language

a1e4a8c90952cbaedcfcb4dd915b2892

1
Sixel_Steg_You

Part 3

Sixel 的 RLE(Run-Length Encoding)语法为 !<数字><字符>,表示将该字符重复 N 次渲染。例如 !100A 表示渲染 100 个 A 对应的六列像素。

正常图像的 RLE 计数通常是个位/十位数,只有藏 flag 处才会出现几千上万的超大计数。利用这个特征可以精准定位。

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
#!/usr/bin/env python3
import sys
import re

def load(path):
with open(path, encoding='utf-8', errors='replace') as f:
return f.read()

def main():
path = sys.argv[1] if len(sys.argv) > 1 else 'flag.txt'
raw = load(path)
print(f"[*] 文件: {path} (长度 {len(raw)})")
print("=" * 60)
print("Part 3: 超长 RLE 缝隙隐藏字符串")
print("=" * 60)

big_rle = re.findall(r'!\d{4,}([^!]{3,}?)!\d{4,}', raw)

print(f" [*] 被超大RLE(计数≥1000)包围的片段: {len(big_rle)} 处")

candidates = []
for seg in big_rle:
s = seg.strip('$-#"?')
for m in re.finditer(r'[_A-Za-z0-9]+', s):
t = m.group(0)
if len(t) >= 4:
candidates.append(t)

if not candidates:
all_seg = re.findall(r'!\d+([ -~]*?)!\d+', raw)
for seg in all_seg:
for m in re.finditer(r'_[A-Za-z0-9_]{3,}', seg):
candidates.append(m.group(0))

seen = set()
unique = []
for t in candidates:
if t not in seen:
seen.add(t)
unique.append(t)

def score(t):
s = 0
if t.startswith('_'):
s += 50
if any(c.islower() for c in t) and any(c.isdigit() for c in t):
s += 30
if 6 <= len(t) <= 24:
s += 5
return s

ranked = sorted(unique, key=lambda t: (-score(t), t))

print(" [Top 10 候选]")
for t in ranked[:10]:
print(f" score={score(t):3d} {t}")

answer = ranked[0] if ranked else None
print(f"\n Part 3 : {answer}")
return answer

if __name__ == '__main__':
main()
1
_H4ve_RRREa

Part 4

由一把梭工具可以看出颜色,然后对应文件刚开始给你定义的颜色

image-20260809130641178

其实也不用那么麻烦啦,让ai帮你写个脚本就行

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
#!/usr/bin/env python3
import sys
import re

def load(path):
with open(path, encoding='utf-8', errors='replace') as f:
return f.read()

def main():
path = sys.argv[1] if len(sys.argv) > 1 else 'flag.txt'
raw = load(path)
print(f"[*] 文件: {path} (长度 {len(raw)})")
print("=" * 60)
print("Part 4: 未使用调色板 RGB -> ASCII")
print("=" * 60)

defined = {}
for m in re.finditer(r'#(\d+);2;(\d+);(\d+);(\d+)', raw):
cid = int(m.group(1))
defined[cid] = (int(m.group(2)), int(m.group(3)), int(m.group(4)))

if not defined:
sys.exit("[!] 未找到任何调色板定义 (#c;2;r;g;b)")

print(f" [*] 共定义调色板: {len(defined)} 个 "
f"(编号 {min(defined)}~{max(defined)})")

used = set()
for m in re.finditer(r'#(\d+)(?![\d;])', raw):
used.add(int(m.group(1)))

defined_ids = set(defined.keys())
used_defined = used & defined_ids
unused = sorted(defined_ids - used)

print(f" [*] 被选用颜色数: {len(used_defined)}")
print(f" [*] 定义但未选用: {unused}")

if not unused:
sys.exit("[!] 没有『定义却未选用』的调色板")

chars = []
for cid in unused:
r, g, b = defined[cid]
triple = (r, g, b)
decoded = ''.join(chr(v) if 32 <= v <= 126 else '.' for v in triple)
print(f" #{cid} = RGB{triple} -> {decoded}")
for v in triple:
if 32 <= v <= 126:
chars.append(chr(v))

answer = ''.join(chars)
print(f"\n Part 4 : {answer}")
return answer

if __name__ == '__main__':
main()
1
vled_17!}

依次拼接

1
NepCTF{Hell0_Sixel_Steg_You_H4ve_RRREavled_17!}

未完待续……