GHSA-56CJ-WGG3-X943

Vulnerability from github – Published: 2026-03-10 18:30 – Updated: 2026-03-10 22:54
VLAI
Summary
Envoy affected by off-by-one write in JsonEscaper::escapeString()
Details

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

```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; }```

Show details on source website

{
  "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()"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…