GHSA-56CJ-WGG3-X943
Vulnerability from github – Published: 2026-03-10 18:30 – Updated: 2026-03-10 22:54Summary
An off-by-one write in Envoy::JsonEscaper::escapeString() can corrupt std::string null-termination, causing undefined behavior and potentially leading to crashes or out-of-bounds reads when the resulting string is later treated as a C-string.
### Details
The bug is in the control-character escaping path in source/common/common/ json_escape_string.h:67.
- The function pre-sizes result to the final length: std::string result(input.size() + required_size, '\');
- For control characters (0x00..0x1f), it emits a JSON escape sequence of length 6: \u00XX.
- It uses sprintf(&result[position + 1], "u%04x", ...), which writes 5 chars + a trailing NUL (\0) starting at result[position + 1].
- Then it does position += 6; and writes result[position] = '\'; to overwrite the NUL.
- If the control character occurs at the end of the output (e.g., the input ends with \x01), then after position += 6, position == result.size(), so result[position] is one past the end (off-by-one), violating std::string bounds/contract.
Concretely, the problematic lines are:
- source/common/common/json_escape_string.h:69 (sprintf(...))
- source/common/common/json_escape_string.h:72 (result[position] = '\';)
Potentially reachable from request-driven paths that escape untrusted data, e.g. invalid header reporting:
- source/common/http/header_utility.cc:538 ~ source/common/http/ header_utility.cc:546 (escapes invalid header key for error text)
Even when this doesn’t immediately crash, it can break the std::string requirement that c_str()[size()] == '\0', which can later trigger UB (e.g., if passed to strlen, printf("%s"), or any C API that expects NUL termination).
```cpp //clang++ -std=c++20 -O0 -g -fsanitize=address -fno-omit-frame-pointer repro_json_escape_asan.cc -o repro_json_escape_asan ASAN_OPTIONS=abort_on_error=1 ./repro_json_escape_asan
include
#include #include #include #include
static uint64_t extraSpace(std::string_view input) { uint64_t result = 0; for (unsigned char c : input) { switch (c) { case '\"': case '\': case '\b': case '\f': case '\n': case '\r': case '\t': result += 1; break; default: if (c == 0x00 || (c > 0x00 && c <= 0x1f)) { result += 5; } break; } } return result; }
static std::string escapeString(std::string_view input, uint64_t required_size) { std::string result(input.size() + required_size, '\'); uint64_t position = 0;
for (unsigned char character : input) {
switch (character) {
case '\"':
result[position + 1] = '\"';
position += 2;
break;
case '\\':
position += 2;
break;
case '\b':
result[position + 1] = 'b';
position += 2;
break;
case '\f':
result[position + 1] = 'f';
position += 2;
break;
case '\n':
result[position + 1] = 'n';
position += 2;
break;
case '\r':
result[position + 1] = 'r';
position += 2;
break;
case '\t':
result[position + 1] = 't';
position += 2;
break;
default:
if (character == 0x00 || (character > 0x00 && character <= 0x1f)) {
std::sprintf(&result[position + 1], "u%04x",
static_cast(character)); position += 6; // Off-by-one when this escape is the last output chunk: // position can become result.size(), so result[position] is out of bounds. result[position] = '\'; } else { result[position++] = static_cast(character); } break; } }
return result;
}
int main() { std::string input(4096, 'A'); input.push_back('\x01'); // ends with a control char -> triggers the buggy path at the end
const uint64_t required = extraSpace(input);
std::string escaped = escapeString(input, required);
std::printf("escaped.size=%zu\n", escaped.size());
unsigned char terminator = static_cast<unsigned char>(escaped.c_str()
[escaped.size()]); std::printf("escaped.c_str()[escaped.size()] = 0x%02x\n", terminator);
// If NUL termination is corrupted, this can read past the logical end.
std::printf("strlen(escaped.c_str()) = %zu\n",
std::strlen(escaped.c_str())); return 0; }```
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/envoyproxy/envoy"
},
"versions": [
"1.37.0"
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/envoyproxy/envoy"
},
"ranges": [
{
"events": [
{
"introduced": "1.36.0"
},
{
"last_affected": "1.36.4"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/envoyproxy/envoy"
},
"ranges": [
{
"events": [
{
"introduced": "1.35.0"
},
{
"last_affected": "1.35.8"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/envoyproxy/envoy"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "1.34.12"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-26309"
],
"database_specific": {
"cwe_ids": [
"CWE-193"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-10T18:30:58Z",
"nvd_published_at": "2026-03-10T20:16:35Z",
"severity": "MODERATE"
},
"details": "### Summary\n\n An off-by-one write in Envoy::JsonEscaper::escapeString() can corrupt\n std::string null-termination, causing undefined behavior and potentially\n leading to crashes or out-of-bounds reads when the resulting string is later\n treated as a C-string.\n\n ### Details\n\n The bug is in the control-character escaping path in source/common/common/\n json_escape_string.h:67.\n\n - The function pre-sizes result to the final length: std::string\n result(input.size() + required_size, \u0027\\\\\u0027);\n - For control characters (0x00..0x1f), it emits a JSON escape sequence of\n length 6: \\u00XX.\n - It uses sprintf(\u0026result[position + 1], \"u%04x\", ...), which writes 5 chars +\n a trailing NUL (\\0) starting at result[position + 1].\n - Then it does position += 6; and writes result[position] = \u0027\\\\\u0027; to overwrite\n the NUL.\n - If the control character occurs at the end of the output (e.g., the input\n ends with \\x01), then after position += 6, position == result.size(), so\n result[position] is one past the end (off-by-one), violating std::string\n bounds/contract.\n\n Concretely, the problematic lines are:\n\n - source/common/common/json_escape_string.h:69 (sprintf(...))\n - source/common/common/json_escape_string.h:72 (result[position] = \u0027\\\\\u0027;)\n\n Potentially reachable from request-driven paths that escape untrusted data,\n e.g. invalid header reporting:\n\n - source/common/http/header_utility.cc:538 ~ source/common/http/\n header_utility.cc:546 (escapes invalid header key for error text)\n\n Even when this doesn\u2019t immediately crash, it can break the std::string\n requirement that c_str()[size()] == \u0027\\0\u0027, which can later trigger UB (e.g., if\n passed to strlen, printf(\"%s\"), or any C API that expects NUL termination).\n \n \n ```cpp\n//clang++ -std=c++20 -O0 -g -fsanitize=address -fno-omit-frame-pointer\n repro_json_escape_asan.cc -o repro_json_escape_asan\n ASAN_OPTIONS=abort_on_error=1 ./repro_json_escape_asan\n#include \u003ccstdint\u003e\n #include \u003ccstdio\u003e\n #include \u003ccstring\u003e\n #include \u003cstring\u003e\n #include \u003cstring_view\u003e\n\n static uint64_t extraSpace(std::string_view input) {\n uint64_t result = 0;\n for (unsigned char c : input) {\n switch (c) {\n case \u0027\\\"\u0027:\n case \u0027\\\\\u0027:\n case \u0027\\b\u0027:\n case \u0027\\f\u0027:\n case \u0027\\n\u0027:\n case \u0027\\r\u0027:\n case \u0027\\t\u0027:\n result += 1;\n break;\n default:\n if (c == 0x00 || (c \u003e 0x00 \u0026\u0026 c \u003c= 0x1f)) {\n result += 5;\n }\n break;\n }\n }\n return result;\n }\n\n static std::string escapeString(std::string_view input, uint64_t\n required_size) {\n std::string result(input.size() + required_size, \u0027\\\\\u0027);\n uint64_t position = 0;\n\n for (unsigned char character : input) {\n switch (character) {\n case \u0027\\\"\u0027:\n result[position + 1] = \u0027\\\"\u0027;\n position += 2;\n break;\n case \u0027\\\\\u0027:\n position += 2;\n break;\n case \u0027\\b\u0027:\n result[position + 1] = \u0027b\u0027;\n position += 2;\n break;\n case \u0027\\f\u0027:\n result[position + 1] = \u0027f\u0027;\n position += 2;\n break;\n case \u0027\\n\u0027:\n result[position + 1] = \u0027n\u0027;\n position += 2;\n break;\n case \u0027\\r\u0027:\n result[position + 1] = \u0027r\u0027;\n position += 2;\n break;\n case \u0027\\t\u0027:\n result[position + 1] = \u0027t\u0027;\n position += 2;\n break;\n default:\n if (character == 0x00 || (character \u003e 0x00 \u0026\u0026 character \u003c= 0x1f)) {\n std::sprintf(\u0026result[position + 1], \"u%04x\",\n static_cast\u003cint\u003e(character));\n position += 6;\n // Off-by-one when this escape is the last output chunk:\n // position can become result.size(), so result[position] is out of\n bounds.\n result[position] = \u0027\\\\\u0027;\n } else {\n result[position++] = static_cast\u003cchar\u003e(character);\n }\n break;\n }\n }\n\n return result;\n }\n\n int main() {\n std::string input(4096, \u0027A\u0027);\n input.push_back(\u0027\\x01\u0027); // ends with a control char -\u003e triggers the buggy\n path at the end\n\n const uint64_t required = extraSpace(input);\n std::string escaped = escapeString(input, required);\n\n std::printf(\"escaped.size=%zu\\n\", escaped.size());\n unsigned char terminator = static_cast\u003cunsigned char\u003e(escaped.c_str()\n [escaped.size()]);\n std::printf(\"escaped.c_str()[escaped.size()] = 0x%02x\\n\", terminator);\n\n // If NUL termination is corrupted, this can read past the logical end.\n std::printf(\"strlen(escaped.c_str()) = %zu\\n\",\n std::strlen(escaped.c_str()));\n return 0;\n }```",
"id": "GHSA-56cj-wgg3-x943",
"modified": "2026-03-10T22:54:36Z",
"published": "2026-03-10T18:30:58Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/envoyproxy/envoy/security/advisories/GHSA-56cj-wgg3-x943"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-26309"
},
{
"type": "PACKAGE",
"url": "https://github.com/envoyproxy/envoy"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "Envoy affected by off-by-one write in JsonEscaper::escapeString()"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.