Morcilla is a PHP extension that acts as a crash oracle for grey-box security scanning. It intercepts specified function calls during PHP execution and returns a JSON log of all observed calls with their arguments via a response header — without modifying the target application.
- A security scanner sends an HTTP request to the target PHP application with two headers:
Morcilla-Key: A secret API token for authenticationMorcilla-Intercept: A comma-separated list of functions/methods to monitor
- Morcilla validates the key using constant-time comparison
- During request execution, it observes calls to the specified functions using the Zend Observer API
- After execution, it returns the results as a base64-encoded JSON payload in the
X-Morcilla-Resultresponse header
When no morcilla headers are present (normal traffic), the extension adds negligible overhead — a single boolean check per function call.
- PHP 8.2 or later (uses the Zend Observer API)
- A C compiler (gcc or clang)
- PHP development headers (
php-dev/php-develpackage, or a PHP source tree)
Copy the ext/morcilla directory to your own project, then:
cd morcilla/
phpize
./configure --enable-morcilla
make
sudo make installAdd to your PHP configuration:
extension=morcilla.so
morcilla.key=your-secret-api-key-herecd php-src/
./buildconf --force
./configure --enable-morcilla [other options...]
make -j$(nproc)cd php-src/
./buildconf --force
./configure --enable-morcilla=shared [other options...]
make -j$(nproc)Then add extension=morcilla.so to your php.ini.
php -d morcilla.key=test -m | grep morcilla
# Output: morcilla
php -d morcilla.key=test -i | grep morcilla
# Shows morcilla configuration section| Directive | Scope | Default | Description |
|---|---|---|---|
morcilla.key |
PHP_INI_SYSTEM |
"" |
Shared secret for authenticating scanner requests. Must be set for the extension to activate. Can only be set in php.ini or via -d, not at runtime. |
Set it in php.ini:
morcilla.key = "a-long-random-secret-token"Or pass it on the command line (useful for testing):
php -d morcilla.key=mysecret -S localhost:8080Important: If morcilla.key is empty or unset, the extension is completely inert — no observer handlers are installed and there is zero performance impact.
| Header | Required | Description |
|---|---|---|
Morcilla-Key |
Yes | Must match the configured morcilla.key value exactly |
Morcilla-Intercept |
Yes | Comma-separated list of function/method names to intercept |
- Plain functions:
file_put_contents,exec,system,mail - Class methods:
PDO::query,mysqli::prepare,SplFileObject::fwrite - Matching is case-insensitive
- Whitespace around commas is trimmed
Results are returned in the X-Morcilla-Result response header as a base64-encoded JSON array.
Start a test application:
php -d morcilla.key=secret123 -S localhost:8080 -t /var/www/appSend a scanning request:
curl -s -D- \
-H "Morcilla-Key: secret123" \
-H "Morcilla-Intercept: PDO::query, file_put_contents, exec, system" \
http://localhost:8080/index.php?id=1Extract and decode the result:
curl -s -D- \
-H "Morcilla-Key: secret123" \
-H "Morcilla-Intercept: PDO::query, file_put_contents, exec" \
http://localhost:8080/index.php?id=1 \
2>&1 | grep X-Morcilla-Result | cut -d' ' -f2 | base64 -d | jq .The decoded JSON is an array of call records:
[
{
"function": "PDO::query",
"file": "/var/www/app/db.php",
"line": 42,
"args": [
"\"SELECT * FROM users WHERE id='1'\""
]
},
{
"function": "file_put_contents",
"file": "/var/www/app/upload.php",
"line": 17,
"args": [
"\"/tmp/upload_a1b2c3\"",
"\"<?php echo 'hello'; ?>\"..."
]
}
]Each record contains:
| Field | Type | Description |
|---|---|---|
function |
string | Fully qualified function name (Class::method or function) |
file |
string | Source file where the call was made (caller location) |
line |
integer | Line number in the source file |
args |
string[] | Argument summaries (see below) |
Arguments are serialized as human-readable strings with type information:
| PHP Type | Summary Format | Example |
|---|---|---|
null |
null |
null |
bool |
true / false |
true |
int |
decimal value | 42 |
float |
decimal value | 3.14 |
string |
"value" (max 256 chars) |
"SELECT * FROM users" |
array |
array(N) |
array(3) |
object |
object(ClassName) |
object(PDOStatement) |
resource |
resource |
resource |
Strings longer than 256 characters are truncated with "..." appended.
| SAPI | Support | Mechanism |
|---|---|---|
| PHP-FPM | Full | sapi_getenv() in RINIT (fast path) |
| CGI / FastCGI | Full | sapi_getenv() in RINIT (fast path) |
| Apache mod_php | Full | sapi_getenv() in RINIT (fast path) |
| LiteSpeed | Full | sapi_getenv() in RINIT (fast path) |
| CLI dev server | Full | $_SERVER fallback (lazy activation) |
| CLI | N/A | No HTTP headers — extension stays inert |
| Embed | N/A | No HTTP headers — extension stays inert |
For production SAPIs (FPM, CGI, Apache, LiteSpeed), headers are checked immediately in RINIT. If no morcilla headers are present, the per-call cost is a single if (!MG(active)) return; check in the begin handler.
For the CLI development server, headers are read lazily from $_SERVER on the first function call.
| Limit | Value | Description |
|---|---|---|
| Max recorded calls | 4096 | Additional calls beyond this limit are dropped |
| Max argument string | 256 | String arguments are truncated at 256 chars |
| Max function name | 512 | Class::method names are truncated at 512 chars |
These limits are defined in php_morcilla.h as MORCILLA_MAX_CALLS and MORCILLA_MAX_ARG_LEN.
- API key is system-level only (
PHP_INI_SYSTEM): It cannot be changed by PHP scripts at runtime viaini_set(). It can only be set inphp.ini, in a pool config, or via the-dCLI flag. - Constant-time key comparison: The key is validated using
php_safe_bcmp()to prevent timing side-channel attacks. - No output modification: The extension only adds a response header. It does not alter the response body.
- Choose a strong key: Use a long, random string (e.g., 32+ characters). The key is the only authentication mechanism.
- Do not enable in production without a key: If
morcilla.keyis empty, the extension is completely inert. - Restrict access at the network level: Consider firewall rules or reverse proxy configuration to limit which clients can send morcilla headers.
Some PHP built-in functions (e.g., strlen, strtolower) have the ZEND_ACC_COMPILE_TIME_EVAL flag. When called with constant arguments, PHP evaluates them at compile time and they do not trigger the observer API. This is expected behavior — these calls are optimized away before execution.
To intercept such functions, ensure they are called with dynamic arguments (e.g., from user input).
No X-Morcilla-Result header in response:
- Check that
morcilla.keyis configured:php -d morcilla.key=test -i | grep morcilla - Verify the
Morcilla-Keyheader value matches exactly (case-sensitive) - Verify
Morcilla-Interceptis not empty - Check that the target functions are actually called during the request
- Check for compile-time evaluation (see above)
Empty JSON array []:
- The intercepted functions were not called during the request, or the function names in
Morcilla-Interceptdon't match (check spelling, useClass::methodformat for methods)
Extension not loading:
- Verify with
php -m | grep morcilla - Check
php --inito confirm the rightphp.iniis being loaded - Check for errors:
php -d morcilla.key=test -r "phpinfo();" 2>&1 | head
This extension is part of the PHP source tree and is subject to the PHP license. See the LICENSE file in the PHP source root for details.