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/ jsonescapestring.h:67.
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/jsonescapestring.h:69 (sprintf(...))
source/common/common/jsonescapestring.h:72 (result[position] = '\';)
Potentially reachable from request-driven paths that escape untrusted data, e.g. invalid header reporting:
source/common/http/headerutility.cc:538 ~ source/common/http/ headerutility.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).
//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
<h1>include <cstdint></h1>
#include <cstdio>
#include <cstring>
#include <string>
#include <string_view>
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<int>(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<char>(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;
{
"nvd_published_at": "2026-03-10T20:16:35Z",
"severity": "MODERATE",
"cwe_ids": [
"CWE-193"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-10T18:30:58Z"
}