//nefariousplan

CVE-2026-3854: rails_env Is a Header Field. The Header Took User Input.

pattern

cve

proof of concept

babeld is the proxy at the front of GitHub Enterprise Server's git push pipeline. gitrpcd is the RPC server downstream. Between them, babeld writes a single header named X-Stat that gitrpcd parses as the source of truth for what code to run and whether to sandbox it. Wiz's writeup says it directly: gitrpcd performs no authentication of its own; it trusts what babeld wrote.

X-Stat is a semicolon-delimited string. Push options are arbitrary key-value pairs the user types on the command line. babeld concatenated the second kind into the first kind verbatim. The parser is last-write-wins.

The header carries rails_env. The same header carries push_option_0. The string between them is one byte the user controls.

X-Stat is the trust handoff between babeld and gitrpcd

When a client opens an SSH connection to git@github.com and runs git push, the request lands at babeld. babeld terminates the SSH session, talks to a separate gitauth service to confirm the user has push access, and forwards the push to gitrpcd over an internal RPC channel. gitrpcd is what actually invokes the on-disk pre-receive hook, runs it, accepts or rejects the push, and writes the objects.

The two services live on different processes, often on different hosts. The wire format gitrpcd uses to make decisions is a single header attached to babeld's RPC call, named X-Stat. From the public PoC at 5kr1pt/CVE-2026-3854, an unmodified X-Stat looks like this:

rails_env=production;
user_id=int:42531;
user_login=paulo.werneck;
repo_id=int:8821;
repo_path=/data/repositories/a/b/cd/ef/12/8821.git;
operator_mode=bool:false;
user_operator_mode=bool:false;
custom_hooks_dir=/data/user/git-hooks;
repo_pre_receive_hooks=[{"id":1,"script":"validate-commit.sh","enforcement":"required"}];
large_blob_rejection_enabled=bool:true;
max_blob_size=int:104857600;
reject_sha_like_refs=bool:true;
push_option_count=int:0

Each field is consumed by gitrpcd with a specific authority. rails_env decides whether the next process runs inside the sandbox or is exec'd directly. custom_hooks_dir decides where the pre-receive hook script is loaded from on disk. repo_pre_receive_hooks is a JSON list of hook objects gitrpcd executes in order. large_blob_rejection_enabled decides whether oversized blobs are rejected. reject_sha_like_refs decides whether refs that look like commit hashes are blocked. max_blob_size is the size limit. The header is the policy. gitrpcd does not consult any other source; it reads the string babeld wrote.

The trust between the two services is held by the architecture. babeld is upstream of authentication. gitrpcd is downstream. babeld is the only writer of X-Stat in any documented codepath. The fields rails_env, custom_hooks_dir, repo_pre_receive_hooks are not user-visible anywhere in the Git wire protocol; they are internal data babeld attaches because gitrpcd needs them. The Wiz writeup states it plainly: gitrpcd performs no authentication of its own; it trusts babeld.

That trust is a single string.

Push option values land in X-Stat by concatenation

git push -o key=value is a documented Git feature. Push options are user-supplied metadata the client sends to the server with a push, used by various servers to enable workflows like merge-on-push directives or skip-CI flags. They are intended to be passed through to the server's pre-receive logic.

The pre-receive logic in this case is gitrpcd. Push options have to reach gitrpcd somehow. babeld's solution was to append them to the same X-Stat header it already builds, with their own field names:

...; push_option_count=int:N; push_option_0=<value>; push_option_1=<value>; ...

The values were inserted verbatim. The public PoC at lysophavin18/CVE-2026-3854-PoC simulates the vulnerable path:

def build_xstat_header(push_options: list[str]) -> str:
    parts = list(BASELINE_FIELDS.items())
    for idx, value in enumerate(push_options):
        # push_option_N=<value>: value is NOT sanitised (vulnerable path)
        parts.append((f"push_option_{idx}", value))
    return ";".join(f"{k}={v}" for k, v in parts)

A semicolon inside a value is not escaped. It is written as-is. gitrpcd's parser splits the resulting header on semicolons:

def parse_xstat_header(header: str) -> dict[str, str]:
    result: dict[str, str] = {}
    for token in header.split(";"):
        if "=" in token:
            key, _, val = token.partition("=")
            result[key.strip()] = val.strip()
    return result

For each key=value token, last write wins. If rails_env=production appears at byte 0 of X-Stat and rails_env=development appears 800 bytes later because a push option supplied normal_value;rails_env=development, the second value is what gitrpcd uses.

There is no separate parser for push option values. There is no escape function. There is no allowlist of fields a push_option_N field is permitted to introduce. babeld's trust hand-off and the user's command-line input share a single string and a single delimiter.

Three overrides and a path resolve to an arbitrary binary

The exploit sets three fields and then names the binary the server should run.

rails_env. When rails_env=production, gitrpcd runs the pre-receive hook in a sandbox. The sandbox restricts what the script can do. When rails_env is anything else, gitrpcd runs the hook directly. Setting rails_env=development (or staging, or any non-production string) is the first override.

custom_hooks_dir. This is the base directory gitrpcd resolves hook script paths against. By default it is something like /data/user/git-hooks. Pointing it at a directory under attacker control is one option. Pointing it at /bin is the option the public PoCs use, because every binary under /bin is then nameable as a relative path from a repo_pre_receive_hooks entry.

repo_pre_receive_hooks. This is a JSON array of hook descriptors. Each descriptor names a script field. gitrpcd resolves the script as custom_hooks_dir + "/" + script and executes the result. If custom_hooks_dir is /bin and script is whoami, the path resolves to /bin/whoami.

The full push command, from the public PoC at 5kr1pt/CVE-2026-3854:

git push \
  -o "x;rails_env=development" \
  -o "x;custom_hooks_dir=/bin" \
  -o 'x;repo_pre_receive_hooks=[{"script":"whoami"}]' \
  origin master

Each push option starts with a benign value (x), a semicolon, and then the field the attacker wants to overwrite. babeld concatenates each value into X-Stat verbatim. After concatenation the header contains, somewhere in its tail:

...; push_option_0=x;rails_env=development;
   push_option_1=x;custom_hooks_dir=/bin;
   push_option_2=x;repo_pre_receive_hooks=[{"script":"whoami"}]; ...

gitrpcd parses the result and takes the last value for each key:

rails_env              = development
custom_hooks_dir       = /bin
repo_pre_receive_hooks = [{"script":"whoami"}]

The pre-receive hook resolves to /bin/whoami. rails_env=development skips the sandbox. gitrpcd executes the binary as the git service user, with the working directory inside the bare repository it just received the push for. Wiz documents the execution context: full filesystem access on shared nodes, as the user that owns every repository.

whoami returns git. Substitute any binary on the host. /bin/sh opens a shell. /usr/bin/curl plus a hook entry of curl and a path traversal in the script field reaches a remote URL and pipes whatever returns to the shell. The pre-receive hook is a process spawned inside the container that holds every repository on the node.

The prerequisite, per the NVD entry, is push access to a single repository on the instance. After one git push, the caller owns the git service user.

X-Stat was internal by convention

This is the internal-only-by-convention pattern. A header, query parameter, or shared name the framework writes to itself and reads with security-relevant authority, with no enforcement of who wrote it. The "internal-only" status is held by documentation and by the absence of any user-supplied path that reaches the same field. Once such a path appears, the documentation is what is defending the field.

X-Stat looked internal. babeld and gitrpcd are GitHub-internal services. Customers do not call them directly. The header is undocumented in any public Git protocol. A reasonable engineer reading babeld's source might believe nothing user-controlled ever reached X-Stat's body.

The push-option codepath is what made that wrong. Push options are part of the Git wire protocol; they have to traverse the proxy. babeld chose to concatenate them into X-Stat because X-Stat was already the channel for everything else gitrpcd needed. The architecture put the user input and the internal trust in the same string.

The pattern repeats across the catalog. CVE-2025-29927 was Next.js's x-middleware-subrequest header, internal between framework fetch() calls, read on every inbound request to short-circuit middleware execution. CVE-2026-34220 was MikroORM's __raw property name, internal between framework branding and SQL inlining, read on any object whose prototype answered the in check. CVE-2026-26210 was KTransformers' scheduler RPC port, internal because the only caller in the codebase connected to localhost, exposed because the docker-run guide bound it on every interface.

In all four, the framework writes to itself and reads with security authority. In all four, no enforcement gates the read. In all four, the patch closes the specific instance and leaves the trust shape intact.

The X-Stat surface here is wider than any of those. rails_env, custom_hooks_dir, repo_pre_receive_hooks, enterprise_mode, user_operator_mode, large_blob_rejection_enabled, reject_sha_like_refs, max_blob_size, push_option_count. Every field gitrpcd reads with authority lives in the same string as the user's push options. The push-option codepath was the path that made the string reachable. Closing the path does not close the string.

The patch closes the path. It does not close the string.

Wiz reported the issue on March 4, 2026. GitHub deployed a fix on GitHub.com within six hours. GHES patches were released on March 10 in versions 3.14.24, 3.15.19, 3.16.15, 3.17.12, 3.18.6, and 3.19.3. Public disclosure followed on April 28.

The fix is a sanitization step in babeld: percent-encode semicolons in push option values before writing them to X-Stat.

- push_option_value: normal_value;rails_env=staging
+ push_option_value: normal_value%3Brails_env=staging

The patched parser sees push_option_0=normal_value%3Brails_env=staging. The substring rails_env=staging is part of the value, not a new field. The injection is neutralized for that delimiter.

The NVD entry does not list 3.14.24, 3.15.19, 3.16.15, 3.17.12, 3.18.6, 3.19.3 as the fixed versions. It lists 3.14.25, 3.15.20, 3.16.16, 3.17.13, 3.18.7, and 3.19.4. The first wave of patches is one minor version behind the record's notion of "fixed."

A repo on GitHub named simondankelmann/cve-2026-3854-test, created April 29, has a one-line description: CVE-2026-3854 patch bypass testing. The repository's commit log is 110 ordered tests of alternative encodings against the patched proxy. The first thirty commits read:

test 1  - direct semicolon
test 2  - url encoded semicolon
test 3  - url encoded uppercase
test 4  - double encoded
test 5  - hex literal
test 6  - unicode fullwidth semicolon
test 6b - unicode fullwidth semicolon retry
test 7  - null byte semicolon
test 7b - semicolon null byte
test 8  - CRLF semicolon
test 9  - newline injection
test 10 - CR injection
test 11 - vertical tab
test 12 - form feed
test 13 - tab char
test 14 - backspace
test 15 - space char
test 16 - bell char
test 17 - escape char
test 18 - DEL char
test 19 - high byte 0x80
test 20 - unicode em dash
test 21 - accented char
test 22 - push_option_count override
test 23 - rails_env override
test 24 - user_operator_mode
test 25 - semicolon rails_env

The shape of that log is a researcher walking down the list of every character a permissive normalizer might fold into a semicolon, every codepath the parser might traverse, every other field the attacker might want to overwrite. That walk is what produces a second wave of patches. The header is a string, and a string admits more delimiters than ;.

The version gap between Wiz's disclosure and NVD's fixed-version field is the public record of that walk landing.

PoC: lysophavin18/CVE-2026-3854-PoC

rails_env is a header field. The header is a string. The string took user input.