|
php.net | support | documentation | report a bug | advanced search | search howto | statistics | random bug | login |
[2021-05-10 09:16 UTC] c dot fol at ambionics dot io
Description:
------------
Hello,
# Bug
TL;DR: You can force the root FPM process to read/write at arbitrary locations using pointers located in the SHM, leading to a privilege escalation from www-data to root.
In PHP-FPM, each worker has an associated scoreboard (`fpm_scoreboard_proc_s`) structure, which contains a PID, its current state (is it handling a request or idle ? Which PHP file is it processing ?), and a few stats alongside (memory and CPU time used). Since both the main process and the worker itself need to read and write this structure, it resides in a shared memory mapping.
The main process controls a `fpm_scoreboard_s` structure, also located in the SHM. It contains the number of active workers, and an array of pointers to `fpm_scoreboard_proc_s` structures.
Here's an example of `fpm_scoreboard_s` and `fpm_scoreboard_proc_s`:
```cpp
pwndbg> p fpm_worker_all_pools->scoreboard
$6 = (struct fpm_scoreboard_s *) 0x7ff114945000
pwndbg> xi 0x7ff114945000
Extended information for virtual address 0x7ff114945000:
Containing mapping:
0x7ff114945000 0x7ff114947000 rw-p 2000 0 /dev/zero (deleted)
Offset information:
Mapped Area 0x7ff114945000 = 0x7ff114945000 + 0x0
File (Base) 0x7ff114945000 = 0x7ff109a31000 + 0xaf14000
pwndbg> p *fpm_worker_all_pools->scoreboard // fpm_scoreboard_s
$7 = {
{
lock = 0,
dummy = '\000' <repeats 15 times>
},
pool = "www", '\000' <repeats 28 times>,
pm = 2,
start_epoch = 1619077029,
idle = 2,
active = 0,
active_max = 0,
requests = 0,
max_children_reached = 0,
lq = 0,
lq_max = 0,
lq_len = 0,
nprocs = 5, // Array size
free_proc = 2,
slow_rq = 0,
procs = 0x7ff114945078 // fpm_scoreboard_proc_s*[]
}
pwndbg> p *fpm_worker_all_pools->scoreboard->procs[0] // fpm_scoreboard_proc_s
$8 = {
{
lock = 0,
dummy = '\000' <repeats 15 times>
},
used = 1,
start_epoch = 1619077029,
pid = 1444,
requests = 0,
request_stage = FPM_REQUEST_ACCEPTING,
...
request_uri = '\000' <repeats 127 times>,
query_string = '\000' <repeats 511 times>,
request_method = '\000' <repeats 15 times>,
...
}
```
As you can see, scoreboard->procs is an array of pointers. Since it is located in the SHM, it can be read/modified by PHP-FPM workers. Since the root process also uses this pointers, this can lead to privilege escalation.
*As a quick test, you can set these pointers to `0xAABBCCDDEE` from a worker process and watch PHP-FPM's main process crash while trying to spawn a new one.*
# Exploit primitives
What does the main process do with these `scoreboard->procs` pointers ? When a worker dies, its scoreboard proc will be marked as unused by the main process using `fpm_scoreboard_proc_free()`. When a new one spawns, `fpm_scoreboard_proc_alloc()` will be used to "link" the new worker to an unused scoreboard.
Let's check `fpm_scoreboard_proc_free()` first: the dying process' PID has been previously obtained by the worker shutdown procedure, and from this a `child_index` has been obtained. It is the index of the dead worker's scoreboard structure in the `scoreboard->procs` array. If this structure's `used` flag is set, it is zeroed: 1168 bytes are set to zero.
When a worker spawns, it needs a `fpm_scoreboard_proc_s` structure. `fpm_scoreboard_proc_alloc()` iterates over `scoreboard->procs` and find an unused structure (by checking that the `used` field is *zero*), and mark it as used. This will effectively change a `0` int to a `1`.
These are really bad primitives but this is enough to get a privilege escalation. I can get into more details if you need to, and/or provide an exploit.
# Fix
I might be very wrong, but I have a few ideas since I've read this code a lot.
1. Get rid of pointers
Maybe you could change `scoreboard->procs` to be an array of `fpm_scoreboard_proc_s` instead of a an array of pointers ? Since the array access index is properly checked (for instance in `fpm_scoreboard_proc_free`, child_index is bound-checked), this would prevent the edition of pointers. No pointers no problems.
2. Get rid of scoreboard->nprocs
You always allocate wp->config->pm_max_children scoreboard procs (that is, scoreboard->nprocs == wp->config->pm_max_children). I don't believe workers need to access nprocs. Even if they did, they can't be able to change it. Otherwise, a worker can set it to a huge value and when the main process tries to go through the ->procs array, it will read OOB and crash.
Test script:
---------------
You can use a PHP sandbox escape exploit to get arbitrary R/W in a worker's memory. Since we don't have this, let's just use GDB to "emulate" this (you need symbols):
## Spawn PHP-FPM and check PIDs
$ service php-fpm restart
$ ps faux | grep php-fpm
root 1242 ... php-fpm: master process (/etc/php/8.0/fpm/php-fpm.conf)
www-data 1320 ... \_ php-fpm: pool www
www-data 1321 ... \_ php-fpm: pool www
## Trigger bug
We'll change the shared memory from the first child (1320):
$ sudo gdb -q -p 1320
pwndbg> p fpm_scoreboard->procs[0] = 0x11223344
Almost instantly, the root process crashes.
## Debug infos
Program received signal SIGSEGV, Segmentation fault.
Backtrace:
```
#0 fpm_request_is_idle (child=child@entry=0x5587c0bf2a10) at ./sapi/fpm/fpm/fpm_request.c:293
#1 0x00005587bfe1e691 in fpm_pctl_perform_idle_server_maintenance (now=0x7ffd515e0af0) at ./sapi/fpm/fpm/fpm_process_ctl.c:328
#2 fpm_pctl_perform_idle_server_maintenance_heartbeat (ev=<optimized out>, which=<optimized out>, arg=<optimized out>) at ./sapi/fpm/fpm/fpm_process_ctl.c:481
#3 0x00005587bfe1a93a in fpm_event_fire (ev=0x5587bff964a0 <heartbeat>) at ./sapi/fpm/fpm/fpm_events.c:487
#4 fpm_event_loop (err=err@entry=0) at ./sapi/fpm/fpm/fpm_events.c:467
#5 0x00005587bfe14bc7 in fpm_run (max_requests=0x7ffd515e0d7c) at ./sapi/fpm/fpm/fpm.c:113
#6 0x00005587bfbd6a64 in main (argc=argc@entry=4, argv=argv@entry=0x7ffd515e12b8) at ./sapi/fpm/fpm/fpm_main.c:1827
#7 0x00007f4370d6d0b3 in __libc_start_main (main=0x5587bfbd6130 <main>, argc=4, argv=0x7ffd515e12b8, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7ffd515e12a8) at ../csu/libc-start.c:308
#8 0x00005587bfbd7e5e in _start () at ./sapi/fpm/fpm/fpm_main.c:1151
```
Crash RIP and RAX:
```
0x5587bfe1f8d0 <fpm_request_is_idle+32> cmp dword ptr [rax + 0x30], 1
RAX == 0x11223344
```
Patchesfpm_scoreboard_proc_oob_fix_v4.patch (last revision 2021-10-10 19:20 UTC by bukka@php.net)fmp_scoreboard_proc_oob_v3.patch (last revision 2021-05-31 21:55 UTC by bukka@php.net) fmp_scoreboard_proc_oob_v2.patch (last revision 2021-05-31 21:16 UTC by bukka@php.net) fmp_scoreboard_proc_oob_v1.patch (last revision 2021-05-31 13:32 UTC by bukka@php.net) Pull Requests
Pull requests:
HistoryAllCommentsChangesGit/SVN commits
|
|||||||||||||||||||||||||||
Copyright © 2001-2025 The PHP GroupAll rights reserved. |
Last updated: Sun Oct 26 07:00:01 2025 UTC |
Hello, -- 1 ------------- The patch does not fix every read/write access to scoreboard->procs[]. Example funcs: fpm_scoreboard_proc_alloc() -> scoreboard->procs[i]->used = 1; fpm_scoreboard_proc_free() -> memset(scoreboard->procs[child_index], 0, sizeof(struct fpm_scoreboard_proc_s)); -- 2 ------------- Also, there is a mistake in the patch: fpm_scoreboard_proc_get_from_child() tries to find the lower and upper bounds for ptr addresses, but the computation of the lower bound is wrong: ptrdiff_t proc_end = proc_start + (wp->config->pm_max_children - 1) * sizeof(struct fpm_scoreboard_proc_s *); The last operand should be sizeof(struct fpm_scoreboard_proc_s), not sizeof(struct fpm_scoreboard_proc_s *) -- 3 ------------- Lastly, I kindly reiterate my suggestion of making scoreboard_s.procs an array of fpm_scoreboard_proc_s instead of an array of *pointers* to fpm_scoreboard_proc_s. In the current design, every time you want to access a scoreboard_proc, you will use its index (child->scoreboard_i). As a consequence, the change won't cause a loss of usability. Even better, you don't have to change the prototype of fpm_scoreboard_proc_get() ! You'd have (pseudo-ish code): ``` struct fpm_scoreboard_s { ... struct fpm_scoreboard_proc_s procs[]; }; int fpm_scoreboard_init_main() /* {{{ */ { ... scoreboard_size = sizeof(struct fpm_scoreboard_s) + (wp->config->pm_max_children) * sizeof(struct fpm_scoreboard_proc_s); ... } struct fpm_scoreboard_proc_s *fpm_scoreboard_proc_get(struct fpm_scoreboard_s *scoreboard, int child_index) /* {{{*/ { ... return &scoreboard->procs[child_index]; // no prototype change, we just return pointer } /* }}} */ ``` CharlesHello, First of, sorry for the late warning, I told you as soon as I knew. I still can't access the patch file ("You have no access to bug #81026"). CharlesHi Stas, so I have done a bit more testing and haven't found any issue so think it's probably good to go. I'm not exactly sure about handling of security fixex as I think it's usually handled by RMs / you. This one is just for 7.4 and up as it's too risky for 7.3 so I could technically merge it myself. If you want me to do it, please let me know and I will do it. Otherwise if you want to apply it yourself, please apply the patch to PHP-7.4 like git am --signoff < fpm_scoreboard_proc_oob_fix_v4.patch It should work fine with PHP-7.4 (I tested that). I also checked the merges and there will be one conflict in fpm_scoreboard.h when you merge to PHP-8.0 which should look like this diff --cc sapi/fpm/fpm/fpm_scoreboard.h index 060eddea98,9d5981e1c7..0000000000 --- a/sapi/fpm/fpm/fpm_scoreboard.h +++ b/sapi/fpm/fpm/fpm_scoreboard.h @@@ -63,8 -63,7 +63,12 @@@ struct fpm_scoreboard_s unsigned int nprocs; int free_proc; unsigned long int slow_rq; ++<<<<<<< HEAD + struct fpm_scoreboard_s *shared; + struct fpm_scoreboard_proc_s *procs[]; ++======= + struct fpm_scoreboard_proc_s procs[]; ++>>>>>>> PHP-7.4 }; int fpm_scoreboard_init_main(); That needs a manual resolution to look like struct fpm_scoreboard_s *shared; struct fpm_scoreboard_proc_s procs[]; It means keeping the shared pointer but changing the procs. So after fixing the git diff should look like this: diff --cc sapi/fpm/fpm/fpm_scoreboard.h index 060eddea98,9d5981e1c7..0000000000 --- a/sapi/fpm/fpm/fpm_scoreboard.h +++ b/sapi/fpm/fpm/fpm_scoreboard.h @@@ -63,8 -63,7 +63,8 @@@ struct fpm_scoreboard_s unsigned int nprocs; int free_proc; unsigned long int slow_rq; + struct fpm_scoreboard_s *shared; - struct fpm_scoreboard_proc_s *procs[]; + struct fpm_scoreboard_proc_s procs[]; }; Once committed, further merges to PHP-8.1 and master should be fine.Hello, First, to trigger this bug, you need to be able to execute PHP code. For instance, as an attacker, you can reach something such as eval($_POST['stuff']). Evidently, this is more convoluted in practice: maybe you can overwrite part of a PHP file, maybe you can reach unserialize($controlled_data), etc. So, this is the starting point: you control some PHP code on the server. Then, you need to use a binary bug in the PHP interpreter to "escape". This could be a use after free, a buffer overflow, a type confusion... Imagine you have an internal PHP function (thus in C) which takes a zval as argument. The sole goal of this (imaginary) function is to add an element into the zval, which is supposed to be an array. The code would look like this: PHP_FUNCTION(add_some_value_into_array) { ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_ZVAL(my_zval) ZEND_PARSE_PARAMETERS_END(); // Add element at index 0 in the array add_index_string(HASH_OF(my_zval), 0, "hello !"); RETURN_TRUE; } Unluckily, the programmer forgot to add the check if(Z_TYPE_P(my_zval) == IS_ARRAY) before using the zval as an array. Therefore, a local attacker can call: add_some_value_into_array(0x1234). When PHP tries to use my_zval (which contains a zend_long) as an array, it will dereference my_zval.val.arr, which is in fact my_zval.val.long. So the C pointer it tries to dereference is 0x1234... Therefore, as an attacker, I can make PHP use an arbitrary pointer as an array. This gives me total control over the zend_array contents, and most notably its zend_array.pDestructor element, which is a function pointer. Now, let's say I built a fake array which contains a single element, at index 0. When add_index_string gets called, $arr[0] will get deleted before "hello !" is added. Therefore, zval.value.arr->pDestructor(zval.value.arr->arData[0]) (or something like that) gets called. Since I control both these values, I can now make PHP call an arbitrary C function with an arbitrary argument. That's the "escape" of PHP's sandbox. You're not executing arbitrary PHP anymore, you're executing machine instructions. Protections such as disable_functions and open_basedir have no effect here. This gives the attacker complete control over the program, even though s/he hasn't used any "code execution" PHP function such as system, exec, etc. It's even better: s/he can now access the whole memory of the current process, which s/he wouldn't be able to do if I just executed another program with PHP's system(). This is the starting point of the exploit for PHP-FPM, where you need to access a worker's SHM. The sandbox escape bug I used in reality is https://github.com/cfreal/exploits/blob/master/php-SplDoublyLinkedList-offsetUnset/exploit.php You can read the header comments to understand the bug. Hope this makes sense, Charles