话虽如此,不过我做事有种偏执的随意性。只要我觉得有趣的东西,即使有些明知不可为我依然会想办法为之。反之,无趣的东西即使是人生必需品我也嗤之以鼻。
那么,来看看我们尽可能打造得足够有趣的2026 HITszCTF吧。
这里大概只会挑一些我参与出题/验题的题写一写。For any other challenge, you can always ask AI to help you!
Crypto#
primeRSA#
from Crypto.Util.number import getPrime, bytes_to_long
import re
flag = open("flag.txt", "rb").read().strip()
m = bytes_to_long(flag)
assert re.fullmatch(r"flag{[A-Za-z0-9_]+}", flag.decode())
n = getPrime(1024)
e = 0x10001
c = pow(m, e, n)
print(f"{n=}")
print(f"{e=}")
print(f"{c=}")python没什么可说的,是素数所以,直接套进RSA的解密公式就行。课件里有提到过RSA的加解密原理,这题作为签到题我觉得蛮合理的。
catInfinity#
from secrets import randbits, choice
from Crypto.Util.number import isPrime, getPrime, bytes_to_long
import string
def pad(m: bytes, alphabet: bytes):
return m + bytes([choice(alphabet) for _ in range(64 - len(m))])
flag = open("flag.txt").read().strip().encode()
assert flag.startswith(b"HITCTF{") and flag.endswith(b"}")
e = 65537
bits = 1024
alphabet = (string.ascii_letters + string.digits).encode()
B = int(input("🎫 > "))
assert 1 < B < bits, "🤬"
while True:
odd = randbits(bits - 1) | 1
p = (B * odd**2 + 2) // 8
if isPrime(p):
break
q = getPrime(bits)
assert p != q, "🤬"
n = p * q
m = bytes_to_long(pad(flag, alphabet))
c = pow(m, e, n)
print(f"{n = }")
print(f"{e = }")
print(f"{c = }")python这题姑且算是2026 LilacCTF-myRSA ↗的revenge,解法就是传进去B=6使得p满足CM形式,然后随机取曲线利用Lenstra’s Elliptic Curve Mfactorization分解n。具体的原理参照LilacCTF的writeup,这里不再赘述。
from sage.all import *
from Crypto.Util.number import inverse, long_to_bytes
N =
c =
e = 65537
F = Zmod(N)
while True:
x, y = (F**2).random_element()
u, v, _ = EllipticCurve([0, y**2 - x**3])(x, y) * N
if int(gcd([u])) > 1:
break
p = int(gcd([u]))
q = N // p
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
m = pow(c, d, N)
print(long_to_bytes(m).decode())pythonpolyLFSR: clock#
import os
from lfsr import LFSR
flag = open("flag.txt", "rb").read().strip()
cipher = LFSR(os.urandom(16))
while True:
op = input("🎫 > ")
if op == "flag":
print(cipher.encrypt(flag).hex())
elif op == "enc":
pt = bytes.fromhex(input("🔒 > "))
print(cipher.encrypt(pt).hex())pythonclass LFSR:
TAPS = [0, 1, 2, 7]
FILTER = [85, 45, 76, 54, 45, 35, 39, 37, 117, 13, 112, 64, 75, 117, 21, 40]
PRE_ROUNDS = 128
def __init__(self, key):
self.state = [bit for byte in key for bit in self._byte_bits(byte)]
for _ in range(self.PRE_ROUNDS):
self.clock()
@staticmethod
def _byte_bits(byte):
return ((byte >> shift) & 1 for shift in range(7, -1, -1))
@staticmethod
def _xor_bits(bits):
value = 0
for bit in bits:
value ^= bit
return value
@staticmethod
def _thr(bits):
return bits[min(sum(bits), len(bits) - 1)]
def clock(self):
w = [self.state[index] for index in self.FILTER]
fdback = self._xor_bits(self.state[index] for index in self.TAPS)
self.state = self.state[1:] + [fdback]
return self._thr(w)
def encrypt(self, data):
return bytes(
byte ^ sum(self.clock() << shift for shift in range(7, -1, -1)) for byte in data
)python搬运的2025 WWCTF的题,不过我们特别标明了原作者所以不算侵权!
解法是很简单的统计学:如果 hamster 在以下这些比特位置上选择了相同的索引:{i−128, i−127, i−126, i−121, i},那么它们的异或结果必然为 0。否则,预期结果中 0 和 1 出现的次数应该几乎相同。
实际测得的概率大约是 0.5002 : 0.4998。通过收集大量样本(我用了大约个样本,赛中有选手只用了个样本),就可以将这两种情况区分开来。
exp不放了,随便喂给某个AI应该都能写出来差不多的脚本。
Misc#
path travelling#
这题是个黑盒Misc,只给选手暴露了一个http file server,访问index.html长这样:

访问别的什么文件则会返回404:

不过稍微上手测一下能发现这个http server事实上是存在路径穿越漏洞的:


看到这里你可能会觉得立马就能读到flag了,那么为什么这题赛中是0解呢?
在解释这题的设计之前我首先需要忏悔一波——flag的位置并非常见位置,而是被放在了/root下一个名字奇奇怪怪的、且嵌套了多层的文件夹里。当然这么做是有理由的,但是这就导致这题直接上手做的话确实有点guessy;不过另一方面,题目后续放出了提示,说明了这题容器内的服务是使用qemu起起来的。
聪明的你一定会想到,如果不是直接从raw kernel和initramfs启动的话,qemu运行的系统大概会挂载一块disk。诶,那么我们直接通过路径穿越读挂载点不就能拿到整个disk image了吗?
事实上这题也确实是这么做的,没记错的话可以在/dev/sda1上读到整个disk image。下载下来之后挂载这块disk然后就能愉快地拿到flag啦~
因为这个非预期解过于精彩,似乎还在圈内小小地流行了一把
这么精彩的非预期,当然要出成一道题目纪念一下咯~比起国赛那题足足有32GB大的disk image,我们这题的disk image只有256MB,对选手非常友好!
verijail 2#
下载附件
从课堂习题verijail 1的一种非预期解改编而来。不过似乎课堂上做出verijail 1的选手里没人发现这种非预期(
注意到这个docker里提供了vi命令,而我们刚好能写入.exrc文件,所以向.exrc里写入要执行的命令然后执行vi就能触发RCE。
nowhere#
搬运的2024 kalmarCTF的题(同样标注了原作者!),解法参见maple3142的文章 ↗
事实上直接把这个Dockerfile和python脚本喂给Gemini就能拿到正解,不清楚为什么解题情况这么糟糕…
MineAnalog#
用MC红石电路写的一个flag校验逻辑,把几百个(具体几百个我也忘了…)拉杆作为flag的0/1 bit输入,目标是找到能点亮所有红石灯的输入。
观察一下就会发现这么大规模的红石电路事实上就是一堆从拉杆直接连接到红石灯的导线,默认情况下一侧的红石灯是全亮,而另一侧红石灯是全灭。并且红石灯默认情况下的亮灭只取决于导线上有没有非门。
所以直接跑到红石灯一侧对着看每一位有没有非门就能确定flag这一bit的输入是0还是1
当然预期解是手写存档解析脚本然后拿到0/1 bit。
PyJail:fault#
目标是要写个Fault Tolerant的Python Quine程序。如果直接检索题面的话能检索到stackexchange上的一个问题 ↗,不过这个问题的要求是写Fault Tolerant的hello world程序。
对照问题下@lydxn的回答可以知道Fault Tolerant的Python hello world怎么写,然后用一些自压缩技术就能搞定Fault Tolerant的Python Quine。
Reverse#
因为逆向不方便贴附件所以只大概讲讲思路吧。
25時、ピクセルで。#
使用MonoGame Framework ↗开发的一个小游戏,玩家需要控制角色走过一块带伤害的砖块到达exit的位置,到达后游戏会与服务器通信获得flag。玩家的初始hp为1,当踏上带伤害的砖块时hp会-1。游戏提供一个药水道具,使用后hp同样会-1。当玩家hp为0时会重置游戏关卡。(注意,hp<0时不会重置!)
这题的解法不拘一格,hack一点的思路就是用CE直接改生命值,把初始生命改高一点自然就能通关;不过因为值为1的变量在游戏内存里相当多,所以需要一点技巧才能准确定位到hp对应的变量。(具体技巧请咨询@oldkingOK ↗)
预期解是利用这里的逻辑漏洞,在砖块造成伤害的一帧使用药水道具,此时hp会变成1-1-1=-1,成功避开了hp为0时重置关卡的逻辑,从而顺利通关。
另外的一些预期解则是完整逆向游戏和服务器的通信逻辑然后伪造请求直接拿flag,或者是直接利用dotnet加载程序集后反射调用游戏的getFlag函数,向服务器发送请求拿flag。
Order Of Orange#
zig手搓的魔改AES+小众神秘orange密码算法,然后让AI迁移到了cpp,并编译到了wasm32-wasi,编译过程中使用Polaris Obfuscator进行了linearMBA、aliasaccess和flattening混淆。
这题出出来就是为了烧公开赛道的选手的token,没别的想法:P
Secret Simulated Sekai#
用MC function提供的计分板操作实现了一些电路组件然后搓了个ARX+SPN的密码结构。因为结构很规整所以可以很方便地手写parser解析MC function然后还原出密码算法并解密。
Ergotic Elastic Evolution#
用ELVM ↗编译得到的产物,是个栈机。交给AI总结不同的case对应的逻辑就能恢复出原本的算法。
dis.dis#
一个魔改了opcodes的pyc,给出了经过魔改的python interpreter。参考kholia/dedrop ↗,写一个包含(几乎)所有 opcode 的 Python 文件,然后让它们分别生成两个 pyc,之后通过比较 opcode 可以构造出 mapping,后面就能开心地反编译了。
当然直接交给AI也能恢复出opcodes。
Web#
fakeUpload#
require 'sinatra'
require 'erb'
require 'fileutils'
TEMPLATE_DIR = 'templates'
FileUtils.mkdir_p(TEMPLATE_DIR)
set :bind, '0.0.0.0'
set :port, 8000
set :show_exceptions, false
get '/' do
templates = Dir.glob(File.join(TEMPLATE_DIR, '*.erb')).map { |f| File.basename(f) }
content_type 'text/html'
<<~HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Renderer</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--md-primary: #6750A4;
--md-on-primary: #FFFFFF;
--md-primary-container: #EADDFF;
--md-on-primary-container: #21005D;
--md-secondary: #625B71;
--md-secondary-container: #E8DEF8;
--md-surface: #FFFBFE;
--md-surface-variant: #E7E0EC;
--md-on-surface: #1C1B1F;
--md-on-surface-variant: #49454F;
--md-outline: #79747E;
--md-outline-variant: #CAC4D0;
}
body {
min-height: 100vh;
background: var(--md-surface);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--md-on-surface);
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
}
.container {
background: var(--md-surface);
border-radius: 28px;
padding: 48px;
max-width: 520px;
width: 100%;
box-shadow:
0 2px 6px 2px rgba(0, 0, 0, 0.04),
0 1px 2px rgba(0, 0, 0, 0.06);
border: 1px solid var(--md-outline-variant);
}
.logo {
width: 48px;
height: 48px;
background: var(--md-primary-container);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.logo svg {
width: 28px;
height: 28px;
fill: var(--md-on-primary-container);
}
h1 {
color: var(--md-on-surface);
text-align: center;
margin-bottom: 8px;
font-size: 1.75rem;
font-weight: 600;
letter-spacing: -0.5px;
}
.subtitle {
color: var(--md-on-surface-variant);
text-align: center;
margin-bottom: 32px;
font-size: 0.875rem;
font-weight: 400;
}
form {
display: flex;
flex-direction: column;
gap: 24px;
}
.input-group {
position: relative;
}
.input-group label {
position: absolute;
left: 16px;
top: -8px;
background: var(--md-surface);
padding: 0 4px;
font-size: 0.75rem;
color: var(--md-primary);
font-weight: 500;
}
input[type="text"] {
width: 100%;
padding: 16px;
border: 1px solid var(--md-outline);
border-radius: 4px;
background: transparent;
color: var(--md-on-surface);
font-size: 1rem;
font-family: inherit;
transition: all 0.2s ease;
}
input[type="text"]:hover {
border-color: var(--md-on-surface);
}
input[type="text"]:focus {
outline: none;
border-color: var(--md-primary);
border-width: 2px;
padding: 15px;
}
input[type="text"]::placeholder {
color: var(--md-on-surface-variant);
}
button {
padding: 0 24px;
height: 40px;
background: var(--md-primary);
border: none;
border-radius: 20px;
color: var(--md-on-primary);
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
letter-spacing: 0.1px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
align-self: flex-end;
}
button:hover {
box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.3);
}
button:active {
background: #7965AF;
}
button svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.templates-section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--md-outline-variant);
}
.templates-section h2 {
color: var(--md-on-surface-variant);
font-size: 0.75rem;
font-weight: 500;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.template-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.template-item {
padding: 8px 16px;
background: var(--md-surface-variant);
border-radius: 8px;
color: var(--md-on-surface-variant);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.template-item:hover {
background: var(--md-secondary-container);
color: var(--md-on-primary-container);
}
.template-item:active {
background: var(--md-primary-container);
}
.empty-state {
text-align: center;
padding: 24px;
color: var(--md-on-surface-variant);
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2ZM18 20H6V4H13V9H18V20ZM9 13V19H7V13H9ZM15 15V19H17V15H15ZM11 11V19H13V11H11Z"/>
</svg>
</div>
<h1>Template Renderer</h1>
<p class="subtitle">Enter a template path to render your ERB files</p>
<form action="/render" method="post">
<div class="input-group">
<label for="template">Template Path</label>
<input type="text" id="template" name="template" placeholder="e.g., cyberpunk.erb" required>
</div>
<button type="submit">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5V19L19 12L8 5Z"/>
</svg>
Render
</button>
</form>
<div class="templates-section">
<h2>Available Templates</h2>
<div class="template-list">
#{templates.empty? ? "<p class='empty-state'>No templates found</p>" : templates.map { |t| "<span class='template-item' onclick=\"document.getElementById('template').value='#{t}'\">#{t}</span>" }.join}
</div>
</div>
</div>
</body>
</html>
HTML
end
post '/render' do
template_name = params[:template]
if template_name.nil? || template_name.empty?
status 400
return "Missing template name"
end
template_path = File.join(TEMPLATE_DIR, template_name)
begin
template_content = File.read(template_path)
renderer = ERB.new(template_content)
output = renderer.result(binding)
content_type 'text/html'
return output
rescue Errno::ENOENT
status 404
return "Template not found"
rescue => _
status 500
return "Render Error"
end
endrubyFROM ruby:3.4.8-alpine
RUN adduser -D -h /home/ctf ctf
RUN apk add --no-cache build-base ruby-dev
RUN gem install sinatra puma rackup
COPY --chown=ctf:ctf app.rb /home/ctf/app.rb
COPY --chown=ctf:ctf public /home/ctf/public/
COPY --chown=ctf:ctf templates/ /home/ctf/templates/
COPY --chown=ctf:ctf --chmod=500 readflag /readflag
USER ctf
WORKDIR /home/ctf
CMD ruby app.rbtxt目标是SSTI。
其实是个老套的file buffering trick ↗,虽然没有可供上传的点但大部分web框架都会对稍大点的file upload请求做磁盘缓存,构造payload然后直接POST,从/proc/self/fd/...读到后拿去渲染就能SSTI了。
后记#
- 赛前就预料到了Re会被AI打爆的情况,所以对不同的赛道分发了不同的题目,然后给公开赛道的逆向题狠狠地加了难度😇 希望你们喜欢
Logical Layered Linker - 因为AI的强势崛起导致大部分题目事实上都能被AI很快解决,所以没标明具体哪题是签到题;不过看起来大部分选手对AI的使用还是不太熟练…
- Pwn相较于去年的降低了难度,反而成了了赛中解题率最高的类别,明明赛前我们都以为Pwn的门槛会更高一点的(
- Web和Misc的解题情况远逊于出题人预期😣
- Crypto里较为简单的
Homura Hash和twisted??居然成功抵挡住了AI,有点出乎我的意料😮💨
