Skip to content
This repository was archived by the owner on May 27, 2026. It is now read-only.

Python code injection vulnerability#1392

Merged
rodaine merged 1 commit into
bufbuild:mainfrom
evilgensec:fix-python-string-injection
May 27, 2026
Merged

Python code injection vulnerability#1392
rodaine merged 1 commit into
bufbuild:mainfrom
evilgensec:fix-python-string-injection

Conversation

@evilgensec

Copy link
Copy Markdown
Contributor

Summary

Unescaped validate.rules string values are interpolated into Python source by python/protoc_gen_validate/validator.py. The rendered source is then executed via exec(), so a malicious .proto whose validation rule contains a Python-source breakout (a literal " followed by arbitrary expressions) achieves arbitrary code execution the first time validate(msg) is called on a message of that type.

This is the Python analogue of the Java vulnerability tracked in #1385.

Description

  • Type: Code injection in generated Python validator code, executed via exec().
  • Source: validate.rules option values (string.const, string.prefix, string.suffix, string.contains, string.not_contains, string.pattern, and the same set inside in_template / const_template).
  • Sink: Python source string built by Jinja2 templates in python/protoc_gen_validate/validator.py, executed at validator.py:68 and :217:
    exec(func, globals(), local_vars)
    with func being the rendered template source.
  • Affected templates (unsafe "{{ … }}" interpolations between literal Python quote characters):
    • const_template (line 303-314): if {{ name }} != "{{ o.string['const'] }}":, error-message string, plus bool/bytes equivalents.
    • in_template (line 325, 329): error-message strings referencing value['in'] / value['not_in'].
    • string_template (line 379-396): pattern, prefix, suffix, contains, not_contains and their error messages.
  • Impact: Arbitrary Python execution in the validator process. The threat model is "compile a generated Python stub from an attacker-influenced .proto and call validate(msg)" — realistic for CI pipelines, multi-tenant build systems, schema-registry / buf push ingestion, and code that uses google.protobuf.Any with dynamic descriptor lookup.

Reproducer (against current main)

// evil.proto
syntax = "proto3";
import "validate/validate.proto";

message Evil {
  string evil_field = 1 [(validate.rules).string.contains =
    "x\" + __import__('os').system('touch /tmp/pgv-pwned') + \"y"];
}
protoc --python_out=. --validate_out=lang=python:. -I. -I/path/to/pgv evil.proto
python3 -c "
from protoc_gen_validate.validator import validate
import evil_pb2
msg = evil_pb2.Evil()
msg.evil_field = 'x'
try: validate(msg)
except Exception: pass
"
ls /tmp/pgv-pwned   # exists -> RCE fired

Generated source produced by the unpatched template (excerpt):

if not "x" + __import__('os').system('touch /tmp/pgv-pwned') + "y" in p.evil_field:
    raise ValidationFailed("p.evil_field does not contain x" + __import__('os').system('touch /tmp/pgv-pwned') + "y")

os.system runs when the if-expression is evaluated (the subsequent NameError on bare evil_field is harmless — the side effect already fired). With small payload adjustments the same pattern fires in every other sink listed above.

Fix in this PR

Add _pyrepr(value) near the top of validator.py — a thin wrapper around repr() so the helper's intent is explicit at the call site. Pass repr=_pyrepr into every affected Template(...).render(...) call. Replace each unsafe "{{ field }}" interpolation with {{ repr(field) }} so the rendered token is a single, properly-escaped Python literal. Restructure error-message strings to do runtime concatenation with a separately-escaped literal:

raise ValidationFailed("{{ name }} does not contain " + {{ repr(s['contains']) }})

This preserves the existing user-visible error text on benign input, keeps re.search regex semantics for {{ s['pattern'] }} (Python's repr produces a non-raw literal whose backslashes evaluate back to the original regex), and refuses injection on crafted input.

Verification

Direct exec of the generated source with the original payload across all five string sinks (contains, prefix, suffix, pattern, const) no longer creates the /tmp/pgv-pwned sentinel. The payload is embedded as a Python string literal 'x" + __import__(\'os\').system(...) + "y' and no command runs. Legitimate regex patterns (^\d+@example\.com$) are preserved.

Sinks closed

Template Sites
const_template string / bool / bytes const (value comparison and error message)
in_template in / not_in error messages
string_template pattern, prefix, suffix, contains, not_contains (condition and error message)

Out of scope / follow-ups

  • The bytes template (bytes_template) already relies on Python's str(bytes) repr at most sites, so direct injection there is harder. A follow-up could route bytes interpolations through _pyrepr for defense-in-depth.
  • Enum templates interpolate enum-name strings that are constrained by protobuf's identifier rules; not a direct RCE sink.
  • Other interpolation sites ({{ num['const'] }}, {{ s['len'] }} etc.) consume numeric or boolean values and are safe.

Related

Diff stats

python/protoc_gen_validate/validator.py — +38 / -24, no other files touched.

@CLAassistant

CLAassistant commented May 27, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Comment thread python/protoc_gen_validate/validator.py Outdated
@rodaine rodaine added Bug Reports and/or fixes a bug Python Python language support labels May 27, 2026
@rodaine rodaine changed the title [Security Vulnerability] protoc-gen-validate Python Code Injection Python code injection vulnerability May 27, 2026
… injection)

The Python validator generates Python source from Jinja2 templates and then
exec()'s it (`python/protoc_gen_validate/validator.py:68` and `:217`). Six
template sites in `string_template` and one in `const_template` interpolate
string-typed `validate.rules` option values between literal Python quote
characters, e.g.

    if {{ name }} != "{{ o.string['const'] }}":
    if not "{{ s['contains'] }}" in {{ name }}:
    if re.search(r'{{ s['pattern'] }}', {{ name }}) is None:

A `.proto` file with a crafted rule string can close the surrounding Python
string literal and append arbitrary expressions or statements, which run on
the first call to `validate(msg)` for that descriptor (the generated
function is cached via `@lru_cache`).

This is the Python analogue of the Java code-injection class tracked in
issue/PR bufbuild#1385.

Fix
---

Add a small `_pyrepr` helper that returns `repr(value)`, the canonical
Python-source literal representation, and pass it as `repr=` into every
affected `Template(...).render(...)` call. Rewrite each unsafe
`"{{ ... }}"` interpolation as `{{ repr(...) }}` so the value becomes a
single, properly-escaped Python literal. Error messages that previously
interpolated the same value inside their format string are restructured to
do runtime string concatenation with a separately-escaped literal, e.g.

    raise ValidationFailed("p.field does not contain " + {{ repr(s['contains']) }})

This preserves the existing user-visible error text on benign input,
keeps regex `{{ s['pattern'] }}` semantics (Python's `repr` produces a
non-raw literal whose backslashes evaluate back to the original regex),
and refuses injection on crafted input.

Reproduction (before patch)
---------------------------

    string evil = 1 [(validate.rules).string.contains =
      "x\" + __import__('os').system('touch /tmp/pgv-pwned') + \"y"];

Running validate(msg) on a message of this type touches
`/tmp/pgv-pwned`. After this patch the same payload is embedded as a
Python string literal `'x" + __import__(\'os\').system(...) + "y'` and
no command runs.

Sinks closed
------------

* `const_template`        string/bool/bytes `const`
* `in_template`           `in` / `not_in` (error message)
* `string_template`       `pattern`, `prefix`, `suffix`, `contains`, `not_contains`
@evilgensec
evilgensec force-pushed the fix-python-string-injection branch from 488fe69 to 457e34b Compare May 27, 2026 14:48
@evilgensec evilgensec changed the title Python code injection vulnerability Escape rule values in generated Python validator to prevent code injection May 27, 2026
@evilgensec evilgensec changed the title Escape rule values in generated Python validator to prevent code injection Python code injection vulnerability May 27, 2026

@rodaine rodaine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the patch!

@rodaine
rodaine merged commit 7aba957 into bufbuild:main May 27, 2026
6 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Bug Reports and/or fixes a bug Python Python language support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants