From 95cf395e04a17d2fc1b32345cb9a4ccc2110964d Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Wed, 3 Jun 2026 21:07:05 -0400 Subject: [PATCH] fix: avoid out-of-bounds read when parsing the response status line frankenphp_send_headers parsed the status code with atoi(SG(sapi_headers).http_status_line + 9), assuming a fixed "HTTP/x.y " 9-byte prefix. A script setting a shorter status line, e.g. header("HTTP/"), leaves http_status_line only 5 bytes long, so the +9 offset reads past the buffer. Confirmed with AddressSanitizer (heap-buffer-overflow read in atoi, 3 bytes past a 6-byte estrndup'd region) via an embed build with USE_ZEND_ALLOC=0. PHP already parses the code into http_response_code through sapi_update_response_code() whenever a status line is set, so the reparse was redundant as well as unsafe. Use http_response_code directly. --- frankenphp.c | 16 +++++------- statusline_test.go | 45 +++++++++++++++++++++++++++++++++ testdata/custom-status-line.php | 4 +++ testdata/short-status-line.php | 5 ++++ 4 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 statusline_test.go create mode 100644 testdata/custom-status-line.php create mode 100644 testdata/short-status-line.php diff --git a/frankenphp.c b/frankenphp.c index 6bc3e0c1da..ee5c869c62 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -954,16 +954,12 @@ static int frankenphp_send_headers(sapi_headers_struct *sapi_headers) { return SAPI_HEADER_SENT_SUCCESSFULLY; } - int status; - - if (SG(sapi_headers).http_status_line) { - status = atoi((SG(sapi_headers).http_status_line) + 9); - } else { - status = SG(sapi_headers).http_response_code; - - if (!status) { - status = 200; - } + /* Use the response code PHP already parsed; reparsing http_status_line with + * a fixed +9 offset read out of bounds for lines shorter than 9 bytes, e.g. + * header("HTTP/"). */ + int status = SG(sapi_headers).http_response_code; + if (!status) { + status = 200; } bool success = go_write_headers(frankenphp_thread_index(), status, diff --git a/statusline_test.go b/statusline_test.go new file mode 100644 index 0000000000..5843ab84f4 --- /dev/null +++ b/statusline_test.go @@ -0,0 +1,45 @@ +package frankenphp_test + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/require" +) + +func serveStatusScript(t *testing.T, script string) *http.Response { + t.Helper() + + cwd, _ := os.Getwd() + req := httptest.NewRequest(http.MethodGet, "http://example.com/"+script, nil) + fr, err := frankenphp.NewRequestWithContext(req, + frankenphp.WithRequestDocumentRoot(cwd+"/testdata/", false)) + require.NoError(t, err) + + w := httptest.NewRecorder() + require.NoError(t, frankenphp.ServeHTTP(w, fr)) + + return w.Result() +} + +// A status line shorter than 9 bytes (e.g. header("HTTP/")) once made +// frankenphp_send_headers read out of bounds via atoi(http_status_line + 9). +// Run under -asan to catch a regression. +func TestShortStatusLineDoesNotOverflow(t *testing.T) { + require.NoError(t, frankenphp.Init()) + defer frankenphp.Shutdown() + + resp := serveStatusScript(t, "short-status-line.php") + require.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestCustomStatusLineIsHonored(t *testing.T) { + require.NoError(t, frankenphp.Init()) + defer frankenphp.Shutdown() + + resp := serveStatusScript(t, "custom-status-line.php") + require.Equal(t, http.StatusTeapot, resp.StatusCode) +} diff --git a/testdata/custom-status-line.php b/testdata/custom-status-line.php new file mode 100644 index 0000000000..0a135d7a3f --- /dev/null +++ b/testdata/custom-status-line.php @@ -0,0 +1,4 @@ +