ISCC misc3 wp

扭曲的真相 WP


卡在了最后,没有想到自定义的base62表和二进制序列,看了大佬的博客复现了最后一步

题目结构总览

附件是:

  • attachment-6.rar

解题链路如下:

  1. 解压得到 secret.dattruth.dat
  2. secret.dat 还原出提示文本,以及第二层 RAR 的密码
  3. truth.dat 还原出 recovered_truth.rar
  4. 用密码解压得到 flag.txt
  5. flag.txt 的零宽字符中还原出 final_truth.png
  6. 根据 final_truth.png 的提示,从真正的“二进制序列”中截取 m=4403, n=111 的子串,再按指定字母表做 base62,得到最终谜底

1. 第一层:从 RAR 中拿到 secret.dattruth.dat

先看压缩包内容:

unrar lb "attachment-6.rar"

可以看到内部核心文件为:

  • secret.dat
  • truth.dat

其中:

  • secret.dat 是十六进制文本
  • truth.dat 是超长 ASCII 0/1

2. secret.dat:8 个 hex 一组,按位纵向取列

2.1 观察提示

题面给出的提示核心是:

  • 四位成组
  • 纵向拾取,各归其行
  • 转换即颠倒
  • 自编码

这里最关键的是前两句。secret.dat 本质上是一串 hex 文本,每个 hex 字符是 4 bit,所以可以这样处理:

  • 8 个 hex 字符 为一组
  • 每个 hex 字符展开成 4 bit
  • 把这 8 行、每行 4 bit 看成一个 8 x 4 矩阵
  • 再按“纵向拾取”恢复成 4 个字节

例如,直接拿 secret.dat 开头前 24 个十六进制字符来演示:

efe10b4ae130baa8e11c4da7

按题目的规则,每 8 个 hex 为一组,所以这 24 个 hex 会被切成 3 组:

先看第 1 组:efe10b4a

md2img-1778073754541

所以第 1 组恢复出 4 个字节:

e5 e2 e5 54

第 2 组:e130baa8

md2img-1778073915608

所以第 2 组恢复出:

8f 80 ae 68

第 3 组:e11c4da7

md2img-1778073948785

所以第 3 组恢复出:

96 9d 83 65

把这前三组的结果合在一起,就是:

e5 e2 e5 54 8f 80 ae 68 96 9d 83 65

继续把整个 secret.dat 全部按这个规则处理,再把每组的第 1/2/3/4 个字节分别归到四条输出流中,就能恢复出 4 路文本。

脚本

from pathlib import Path
import numpy as np
import base64

s = Path("secret.dat").read_text().strip()
vals = np.array([int(c, 16) for c in s], dtype=np.uint8)

for bit in range(4):
    bits = ((vals >> bit) & 1).astype(np.uint8)
    line = np.packbits(bits, bitorder="big").tobytes().rstrip(b"\x00")
    print(bit, line.decode("utf-8", "replace"))

2.2 恢复出的关键信息

可以还原出如下内容:

取一个长方形纸带,将其末端翻转与首端粘合后,可以在现实世界中得到莫比乌斯环。
”起点“亦或”终点“。
它的曲面在三维空间中被扭曲嵌入,蚂蚁实际上需要爬行两圈的长度才能真正返回三维视角下的同一出发点,这种返回既是空间上的也是方向上的反转。
The key is WXRoOVVyMDYyYXpaQTA5eTRyczVM

最后一行非常关键:

The key is WXRoOVVyMDYyYXpaQTA5eTRyczVM

对它做 Base64 解码:

Yth9Ur062azZA09y4rs5L

这就是下一层 RAR 的密码。


3. truth.dat:前半 XOR 后半倒序,再整体反转

truth.dat 是超长 ASCII 0/1 串,结合莫比乌斯环相关提示,正确处理方式为:

  1. 把整个文件按字节读入
  2. 对每个 ASCII 字符取最低位,得到真正 bit 串
  3. 设前半段为 a[:half],后半段为 a[half:]
  4. 执行:前半段 XOR 后半段倒序
  5. 再把结果整体反转
  6. 最后每 8 bit 打包成字节

即:

bits = (a[:half] ^ a[half:][::-1])[::-1]
rar = np.packbits(bits, bitorder="big").tobytes()

恢复出的文件头为:

52 61 72 21 1a 07 01 00

也就是标准 RAR5 文件头:

Rar!

说明这一层正确恢复出了真正的 RAR 文件。

from pathlib import Path
import numpy as np

src = Path("./truth.dat")  # 读取这个二进制文件
out = Path("./rrecovered_truth.rar")            # 输出到这个RAR文件

a = np.fromfile(src, dtype=np.uint8) & 1   # 读取每个字节,只保留最低位(0/1)
half = len(a) // 2                         # 取一半长度

# 核心操作:
# 1. a[:half] - 前半段
# 2. a[half:][::-1] - 后半段反转
# 3. 前半 XOR 后半反转
# 4. 整体再反转
bits = (a[:half] ^ a[half:][::-1])[::-1]

# 把bit数组打包成字节(每8位一个字节,大端序)
rar = np.packbits(bits, bitorder="big").tobytes()

out.write_bytes(rar)  # 写入RAR文件

4. 用密码解出 flag.txt

用上一步在 secret.dat 中得到的密码:

Yth9Ur062azZA09y4rs5L

直接解压:

unrar x -o+ -p"Yth9Ur062azZA09y4rs5L" /root/ctf/recovered_truth.rar /root/ctf/attachment6_extract/final/

会得到:

  • flag.txt

5. flag.txt:零宽字符还原出 final_truth.png

5.1 可见部分并不是真 flag

flag.txt 开头看上去像这样:

flag={M4403wkhabdIfRxDN111}

它很像 flag,但实际上不是最终答案。至少有两点可以看出来:

  1. M4403N111 明显在对应 PNG 提示里的参数 m=4403, n=111
  2. 中间的 wkhabdIfRxD 更像干扰项或校验片段,而不是最终谜底

真正有价值的是它后面跟着的大量零宽字符。

5.2 零宽字符映射成 bit

这里主要有两种字符:

  • U+200B ZERO WIDTH SPACE
  • U+200C ZERO WIDTH NON-JOINER

UTF-8 分别是:

  • U+200B -> e2 80 8b
  • U+200C -> e2 80 8c

映射成 bit:

  • U+200B -> 0
  • U+200C -> 1

5.3 正确恢复方式

这一层与上一层很像,但少了最后的整体反转。正确方式是:

png_bits = bits[:half] ^ bits[half:][::-1]
png = np.packbits(png_bits, bitorder="big").tobytes()

恢复出的文件头为:

89 50 4e 47 0d 0a 1a 0a

这就是标准 PNG 文件头,所以可以确定得到的是:

  • final_truth.png
    ffinal_truth
from pathlib import Path
import numpy as np

src = Path("./flag.txt")
out = Path("./ffinal_truth.png")

zwsp = b"\xe2\x80\x8b"  # U+200B
zwnj = b"\xe2\x80\x8c"  # U+200C

data = src.read_bytes()
positions = [p for p in (data.find(zwsp), data.find(zwnj)) if p != -1]
if not positions:
    raise SystemExit("No zero-width payload found")

prefix_end = min(positions)
prefix = data[:prefix_end]
tail = data[prefix_end:]

arr = np.frombuffer(tail, dtype=np.uint8)
if len(arr) % 3 != 0:
    raise SystemExit(f"Unexpected tail length: {len(arr)} (not divisible by 3)")

last_bytes = arr[2::3]
if not np.all((last_bytes == 0x8b) | (last_bytes == 0x8c)):
    raise SystemExit("Tail contains bytes other than U+200B/U+200C terminators")

bits = (last_bytes == 0x8c).astype(np.uint8)
half = len(bits) // 2

# 正确变体:前半段 XOR 后半段倒序,直接按 8 bit 打包
png_bits = bits[:half] ^ bits[half:][::-1]
png = np.packbits(png_bits, bitorder="big").tobytes()

out.write_bytes(png)
print("prefix:", prefix.decode("utf-8", errors="replace"))
print(f"wrote {out} ({len(png)} bytes)")
print("png head:", png[:16].hex())


6. 最后一层:真正的“二进制序列”到底是哪一个?

final_truth.png 里的文字提示如下:

你需要找到一个二进制序列,截取起始位为 m,长度为 n 的子序列,通过 base62 编码得到最终的谜底。

而假 flag 已经把参数直接给出来了:

  • m = 4403
  • n = 111

关键问题只剩一个:

这个“二进制序列”到底指的是哪一个?

这里最容易误入歧途的地方,是把最终 PNG 再继续做图像隐写、位平面分析、LSB 爆破之类的操作。但结合整道题的叙事线索:

  • 第一层拿到的 secret.dat 提供了密码
  • 第二层拿到 truth.dat 恢复出下一层 RAR
  • 第三层从 flag.txt 里恢复出最终 PNG
  • 最终 PNG 又说“利用所有你能找到的提示”

再结合题里反复强调的“每一次收获都有意义”,最合理的解释就是:

最后要使用的“二进制序列”,就是第一层 secret.dathex nibble bit 流

这里把“它到底是怎么来的”写清楚。

secret.dat 原文是一整串十六进制字符,例如它开头就是:

efe10b4ae130baa8e11c4da7...

把每个 hex 字符按原顺序展开成 4 bit:

e -> 1110
f -> 1111
e -> 1110
1 -> 0001
0 -> 0000
b -> 1011
4 -> 0100
a -> 1010
...

然后直接顺次拼接,就得到题目最后要用的那条完整二进制序列:

111011111110000100001011010010101110000100110000101110101010100011100001000111000100110110100111...

也就是说,这一步根本不是再去构造一个新文件,也不是去读 PNG 的像素位,而是:

  1. 读取 secret.dat 原始十六进制字符流
  2. 每个 hex 字符展开为 4 bit
  3. 按原顺序拼接成一条长度为 1608 × 4 = 6432 bit 的总序列
  4. 从这条总序列中,以 0-based 下标取 bitstream[4403:4403+111]
  5. 得到最后要送去 base62 的那 111 bit

实际截出来的 111 bit 是:

000000000001000100010001000000000001000100010001000000000000000000000001000000010000000100010001000000000001000

为了更容易核对,也可以按 8 bit 分组看:

00000000 00010001 00010001 00000000 00010001 00010001 00000000 00000000 00000001 00000001 00000001 00010001 00000000 0001000

最后再把这 111 bit 当作一个大整数,使用 RAR 密码里暗示的字母表做 base62 编码,就得到最终谜底。

这一层最容易想偏到 PNG 隐写上去,但实际上题目要你“回头用上前面已经拿到的线索”,而不是继续在最终图片里深挖。


7. 为什么字母表不是普通 base62?

RAR 密码是:

Yth9Ur062azZA09y4rs5L

其中中间一段非常像人为设计过的提示:

0 | 62 | az | ZA | 09

可以解释为:

  • 0:0-based 起始位
  • 62:base62
  • azZA09:字母表顺序为 a-z + Z-A + 0-9

所以最终不是使用常见的 base62 字母表,而是:

abcdefghijklmnopqrstuvwxyzZYXWVUTSRQPONMLKJIHGFEDCBA0123456789

注意这里大写字母部分是 Z 到 A 的逆序


8. 最终求解脚本

下面给出一份完整脚本,把前面几层全部串起来。你可以只跑最后一段,也可以整条链路全跑一遍。

from pathlib import Path
import base64
import numpy as np

BASE = Path('/root/ctf')
SECRET = BASE / 'attachment6_extract' / 'secret.dat'
TRUTH = BASE / 'attachment6_extract' / 'truth.dat'
FLAG_TXT = BASE / 'attachment6_extract' / 'final' / 'flag.txt'
PNG_OUT = BASE / 'final_truth.png'
RAR_OUT = BASE / 'recovered_truth.rar'

ALPHABET = 'abcdefghijklmnopqrstuvwxyzZYXWVUTSRQPONMLKJIHGFEDCBA0123456789'


def base62_custom_from_bits(bit_string: str, alphabet: str = ALPHABET) -> str:
    value = int(bit_string, 2)
    if value == 0:
        return alphabet[0]

    out = []
    while value:
        value, rem = divmod(value, 62)
        out.append(alphabet[rem])
    return ''.join(reversed(out))


def decode_secret_dat(secret_path: Path):
    hex_text = secret_path.read_text().strip()
    rows = [bytearray() for _ in range(4)]

    for i in range(0, len(hex_text), 8):
        block = hex_text[i:i + 8]
        if len(block) < 8:
            break
        bits = [f'{int(ch, 16):04b}' for ch in block]
        for col in range(4):
            byte_bits = ''.join(row[col] for row in bits)
            rows[col].append(int(byte_bits, 2))

    lines = [r.decode('utf-8', errors='replace') for r in rows]
    return hex_text, lines


def recover_rar_from_truth(truth_path: Path, out_path: Path):
    a = np.fromfile(truth_path, dtype=np.uint8) & 1
    half = len(a) // 2
    bits = (a[:half] ^ a[half:][::-1])[::-1]
    rar = np.packbits(bits, bitorder='big').tobytes()
    out_path.write_bytes(rar)
    return rar


def recover_png_from_flag(flag_path: Path, out_path: Path):
    zwsp = b'​'  # U+200B -> 0
    zwnj = b'‌'  # U+200C -> 1

    data = flag_path.read_bytes()
    positions = [p for p in (data.find(zwsp), data.find(zwnj)) if p != -1]
    if not positions:
        raise ValueError('No zero-width payload found in flag.txt')

    prefix_end = min(positions)
    prefix = data[:prefix_end].decode('utf-8', errors='replace')
    tail = data[prefix_end:]

    arr = np.frombuffer(tail, dtype=np.uint8)
    if len(arr) % 3 != 0:
        raise ValueError(f'Unexpected tail length: {len(arr)}')

    last_bytes = arr[2::3]
    if not np.all((last_bytes == 0x8B) | (last_bytes == 0x8C)):
        raise ValueError('Tail contains non-zero-width payload bytes')

    bits = (last_bytes == 0x8C).astype(np.uint8)
    half = len(bits) // 2
    png_bits = bits[:half] ^ bits[half:][::-1]
    png = np.packbits(png_bits, bitorder='big').tobytes()

    out_path.write_bytes(png)
    return prefix, png


def solve_final_from_secret(hex_text: str, m: int = 4403, n: int = 111) -> str:
    bitstream = ''.join(f'{int(ch, 16):04b}' for ch in hex_text)
    segment = bitstream[m:m + n]
    if len(segment) != n:
        raise ValueError(f'Wanted {n} bits, got {len(segment)} bits')
    return base62_custom_from_bits(segment)


def main():
    hex_text, lines = decode_secret_dat(SECRET)
    print('[+] secret.dat decoded lines:')
    for line in lines:
        print(line)

    key_b64 = lines[-1].split()[-1]
    password = base64.b64decode(key_b64).decode()
    print(f'[+] rar password: {password}')

    rar = recover_rar_from_truth(TRUTH, RAR_OUT)
    print(f'[+] recovered rar head: {rar[:8].hex()}')

    prefix, png = recover_png_from_flag(FLAG_TXT, PNG_OUT)
    print(f'[+] visible fake flag: {prefix}')
    print(f'[+] recovered png head: {png[:8].hex()}')

    final_secret = solve_final_from_secret(hex_text, m=4403, n=111)
    print(f'[+] final secret: {final_secret}')
    print(f'[+] wrapped flag: flag={{{final_secret}}}')


if __name__ == '__main__':
    main()

9. 直接求最后一层的最小脚本

如果前面文件都已经恢复好了,只想直接算最后一层,可以用下面这份精简脚本:

from pathlib import Path

ALPHABET = 'abcdefghijklmnopqrstuvwxyzZYXWVUTSRQPONMLKJIHGFEDCBA0123456789'
M = 4403
N = 111

hex_text = Path('/root/ctf/attachment6_extract/secret.dat').read_text().strip()
bitstream = ''.join(f'{int(ch, 16):04b}' for ch in hex_text)
segment = bitstream[M:M + N]

value = int(segment, 2)
out = []
while value:
    value, rem = divmod(value, 62)
    out.append(ALPHABET[rem])

result = ''.join(reversed(out)) or ALPHABET[0]
print(result)
print(f'flag={{{result}}}')

运行结果为:

olp95YuuF73D5MsK6
flag={olp95YuuF73D5MsK6}

10. 最终答案

按照题目提示:

  • 二进制序列取自 secret.dat 的 hex nibble bit 流
  • 起始位 m = 4403
  • 长度 n = 111
  • 字母表为 abcdefghijklmnopqrstuvwxyzZYXWVUTSRQPONMLKJIHGFEDCBA0123456789

最终得到:

olp95YuuF73D5MsK6

11. 这题的关键坑点

最后总结一下这题几个容易卡住的位置:

  1. flag.txt 开头那个可见的 flag={M4403wkhabdIfRxDN111} 是假 flag
  2. 真正有效的信息是:
    • M4403 -> m = 4403
    • N111 -> n = 111
  3. 最后一层需要回到前面已经得到的线索,不能只盯着 final_truth.png 本身做隐写
  4. base62 的字母表不是常见版本,而是密码里暗示的:
    • a-z + Z-A + 0-9
  5. 最终要取的是 secret.dathex 字符展开后的 bit 流,不是别的文件

整道题其实一直围绕一个主题:

每一层拿到的东西都不是终点,而是下一层真正提示的一部分。

这也是题目名字“扭曲的真相”最贴切的地方。
最后我想说的是misc手年年吃大份

posted @ 2026-05-06 21:14  慕雪ya  阅读(101)  评论(0)    收藏  举报