CTFSHOW2026元旦跨年欢乐赛
CTF部分
热身签到
Challenge
元旦时,我二舅姥爷给我出的密码题
1 | 54515552545455515456547055555566545654495548554855575370515051485150515453705555545755525456537054515551515051485150515450495568 |
Solution
From Decimal, From Hex - CyberChef
FLAG
1 | ctfshow{happy_2026_with_cs2026!} |
HappySong
Challenge
鼓声也可以很燃,虽然只有两个音节
Solution

试了一下 01100011 01110100 二进制转字符得到 ct 就不用再试了,就是这个规律,一点点照着转就行
FLAG
1 | ctfshow{just_a_nice_song} |
Happy2026
Challenge
奇怪的2026
1 |
|
Solution
考的是 PHP 弱类型比较和数组/变量覆盖
我们需要满足 if 语句中的三个条件才能触发 include,从而进行文件包含或代码执行。
$year == 2026弱相等:- PHP 在使用
==比较时,如果一方是数字,另一方是字符串,会尝试将字符串转换为数字。 - 例如:
"2026.0" == 2026为真,"2026abc" == 2026(在旧版本 PHP) 为真。
- PHP 在使用
$year !== 2026强不等:!==比较值和类型,如果我们传入的是字符串"2026.0",虽然值等于 2026,但类型是 String,而右边是 Int,所以条件成立。
is_numeric($year)数字检测:is_numeric()检测变量是否为数字或数字字符串。- 它允许小数形式(如
"2026.0"),但不允许包含非数字字符。
因此可以使用浮点数形式的字符串 2026.0 绕过。
include $happy[$new[$year]]; 是一个嵌套的数组取值操作。假设我们构造 Payload 如下:
- GET 参数
year="2026.0" - 我们需要构造
new为一个数组,使得$new['2026.0']存在。假设我们设$new['2026.0'] = 'cmd'。 - 接着需要构造
happy为一个数组,使得$happy['cmd']存在。 - 最终
include的就是$happy['cmd']的值。
我们尝试利用 php://input 伪协议,因为它允许我们将 PHP 代码作为 POST 数据发送给服务器执行。
构造 Payload:
- URL参数:
1
?year=2026.0&new[2026.0]=k&happy[k]=php://input
- POST 数据:
1
system('ls -al');
执行结果:
1 | drwxrwxrwx 1 www-data www-data 4096 Dec 30 10:20 . |
为了防止 PHP 标签被解析,我们可以使用 Linux 的 base64 命令将 flag.php 文件内容编码为纯文本。
构造 Payload:
- POST 数据:
1
system('base64 flag.php');
获得一串 Base64 字符串。解码后即可看到源码和 Flag。
1 | import requests |
执行结果:
1 | <?php $flag='ctfshow{20848734-d1d4-4ef5-9300-7a093a4d3e9b}'; |
FLAG
1 | ctfshow{20848734-d1d4-4ef5-9300-7a093a4d3e9b} |
SafePIN
Challenge
绝对安全的身份认证系统
Solution
前端实现了一个基于 SHA-256 的确定性 PRNG
源码中的 u32FromHex8 函数是所有随机数的来源:
1 | function u32FromHex8(h8){ |
它提取了 SHA-256 哈希值的前 8 位,采用了小端序重组,b0 是低位,b3 是高位。
1 | async function prng_u32(seed, tag){ |
所有的随机数都是通过 seed + "|" + tag 产生的,这意味着只要知道 seed 就能预知所有的键盘映射和频率微扰
这套系统通过 permute_0_9 改变了数字键对应的声音 ID,使得“按键 1”发出的声音并不一定是“ID 1”。
1 | async function permute_0_9(seed){ |
Math.imul(x, y) 模拟 C 语言风格的 32 位整型乘法处理溢出。返回的数组 a 的键是实际数字,值是对应的 SoundID(soundId = 10 对应 CANCEL 键,soundId = 11 对应 ENTER 键)。
查看 soundParams 函数中的核心计算:
1 | const base = 1050 + soundId*23 + (((x & 0xff) - 128) * 0.35); |
基准频率由 1050 + soundId * 23 决定,SoundID 0-11 的范围大约是 1050Hz - 1303Hz。微扰项 x 是由 prng_u32(seed, "p" + soundId) 产生的。由于微扰范围(约 90Hz)大于 ID 间隔(23Hz),不同 ID 的频率会交叉。我们需要针对当前会话的 seed 算出 12 个绝对的频率指纹点。
最后访问 /seed.php 得到 seed
1 | {"ok":true,"seed":"294f2d41875fde53c5c15273cb675730","token":"a14d15d027318590bc5e227c6c692961","record_url":"\/record.php?token=a14d15d027318590bc5e227c6c692961"} |
编写 Python 代码分析:
1 | import hashlib |
输出结果:
1 | [*] 使用 seed: bf46354bb4019621c2c5ea5af89d525d |
得到 PIN 为 447685,输入得到 flag
FLAG
1 | ctfshow{31e51121-516d-49f2-8c8e-cde7f27eb382} |
SafePassword
Challenge
- 根据情报,J国近年来对我国进行了持续的渗透攻击,我方技术人员经过溯源,发现某个可疑网址。
- 该网址疑似为J国的情报组织任务分配中心,但是我方并没有拿到登陆口令,无法继续深入。
- 已经确认嫌疑账号用户名为jcenter,此人2025年加入该组织,登陆口令未知。
Solution
1 | $expected = getExpectedHash($channelKey); |
PHP 的 == 是弱类型比较。如果一边是字符串,另一边是整数,PHP 会尝试将字符串转换为整数再进行比较。例如:"2025abc" == 2025 的结果是 true。
如何让
$expected变成整数:
观察getExpectedHash和buildExpectedHash函数:- 如果
$channelKey长度超过 64 或者包含特定不可见字符,buildExpectedHash会抛出异常。 - 内部
catch块捕获异常后,会抛出一个新的异常,其错误代码为VERIFY_FAILED,即 2025(这对应了题目背景中“2025年加入组织”的提示)。 getExpectedHash捕获该异常后,调用pickErrorCode。因为2025在ERROR_CODES常量数组中,所以函数最终返回整数2025。
- 如果
利用思路:
- 构造一个非法的
channel_key(例如长度大于 64 的字符串),迫使服务器返回整数2025。 - 寻找一个字符串
access_key,使得它的 MD5 值以2025开头且紧跟一个非数字字符。 - 发送请求,利用
md5("access_key") == 2025绕过验证。
- 构造一个非法的
1 | import requests |
执行结果:
1 | CSRF Token: 27175dd27626768fab073983724c83e3 |
FLAG
1 | ctfshow{7c8914b8-81a4-4ab4-b7ae-d266644b16cf} |
AWDP防御题目
SafeCalc
Challenge
过于简单,不用防御
calc.php
1 |
|
Solution
插入一行针对 $expr 的合法字符白名单校验,只允许数字、基础算术运算符、括号、小数点和空格通过
1 |
|
SafeCard
Challenge
业务功能一定要正常哦
app.py
1 | from flask import Flask, request, render_template |
Solution
- 完善黑名单:增加了
__(防止双下划线方法)、self(防止沙箱逃逸)、[和](防止字典/下标访问)以及attr和base等关键属性。 - 修复过滤逻辑漏洞:原代码使用
re.sub将黑名单词汇替换为空字符串,这存在双重嵌套绕过风险(如 conconfigfig),将其修改为一旦检测到黑名单词汇,直接返回空字符串。
1 | from flask import Flask, request, render_template |
SafePHP
Challenge
不要把环境搞炸了
webService.php
1 |
|
Solution
非预期,把函数全删了就通过了
1 | <?php |
AWDP攻击题目
SafePythonJail
Challenge
Python 很安全,没事的,本来是防御题目,放到攻击题目感觉更好一点。
Solution
核心漏洞点:
在sanitizer.py的_prune_node_exec函数中存在一个逻辑错误:1
2
3
4
5
6def _prune_node_exec(node: ast.AST, policy: Policy) -> ast.AST:
# ...
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
operand = _prune_node_exec(node.operand, policy) # 剪枝了操作数,但结果存在变量 operand 中
return ast.UnaryOp(op=ast.Not(), operand=node.operand) # <--- 却返回了原始的 node.operand
# ...这意味着,任何包裹在
not (...)中的表达式在execute阶段的prune_for_exec处理时,都会保留其原始未修剪的 AST 节点。签名绕过:
canonicalize_for_signing函数对于所有的ast.Not表达式都会返回统一的"NOT(*)":1
2if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
return "NOT(*)"我们可以先用一个合法的
not True获取签名(对应NOT(*)),然后在执行时替换 payload 为not (恶意代码)。由于恶意代码也被prune_for_verify剪枝成False,其生成的规范化字符串依然是NOT(*),从而绕过签名验证。利用链:
- 通过
req(一个Obj实例)获取其__init__.__globals__,从而进入app.py的全局命名空间。 - 在全局空间中找到
_sessions字典和request对象。 - 利用
request.cookies.get('sid')获取当前 session ID。 - 在
_sessions中定位到当前用户的SessionState对象,并调用其__setattr__方法将stage直接修改为 1001。
- 通过
解题思路:
- 骗取签名:向
/prepare发送not (任意合法表达式),拿到nonce和针对NOT(*)的合法签名。 - 构造 Payload:利用
not (恶意代码)结构。 - 外带数据:
- 由于
eval执行结果无法直接看到,我们通过劫持_sessions字典找到自己的 session。 - 将执行结果写入 session.stage 属性。
- 通过访问
/status接口,服务器会将stage的内容以 JSON 形式输出。
- 由于
1 | import requests |
执行结果:
1 | $ ls |
- 标题: CTFSHOW2026元旦跨年欢乐赛
- 作者: Aristore
- 链接: https://www.aristore.top/posts/CTFSHOW2026YuanDan/
- 版权声明: 版权所有 © Aristore,禁止转载。