Skip to content

Off-by-one write in JsonEscaper::escapeString()

Moderate
phlax published GHSA-56cj-wgg3-x943 Mar 10, 2026

Package

gomod github.com/envoyproxy/envoy (Go)

Affected versions

<1.37.1,<1.36.5,<1.35.9,<1.34.13

Patched versions

1.37.1,1.36.5,1.35.9,1.34.13

Description

Summary

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).

//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 <cstdint>
#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;
}```

Severity

Moderate

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
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
Low

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:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

CVE ID

CVE-2026-26309

Weaknesses

No CWEs

Credits