Skip to main content
11 min read

One git push to RCE: the anatomy of CVE-2026-3854 and the parsing bug behind it

One git push to RCE: the anatomy of CVE-2026-3854 and the parsing bug behind it

An authenticated user could run arbitrary commands on GitHub's backend with a single git push. No exploit chain of memory-corruption primitives, no dropped binary, just a standard git client and a carefully chosen push option. On GitHub Enterprise Server that meant full server compromise. On github.com itself, because of the shared multi-tenant backend, it meant reading across tenants: millions of repositories on a shared storage node, regardless of who owned them.

That is CVE-2026-3854 (CVSS 8.7), found by Wiz Research, fixed on github.com the day it was reported, and patched in GitHub Enterprise Server 3.14.24, 3.15.19, 3.16.15, 3.17.12, 3.18.6, and 3.19.3. The alarming footnote: at public disclosure, 88% of GHES instances were still unpatched.

The vulnerability is worth your time not because you run GitHub's infrastructure, but because the root cause is a class of bug that lives in a lot of systems, including probably one of yours: untrusted input passed through a delimited internal header that a downstream service parses with last-write-wins semantics. If any part of that sentence describes your architecture, read on.

TL;DR

  • What: git push supports "push options", arbitrary key-value strings the client sends to the server. GitHub forwarded those values, unsanitized, into an internal HTTP header used between backend services.
  • The header: an internal X-Stat header carried security-critical fields as key=value pairs joined by ;. Downstream services split on ; and built a map with last-write-wins: a duplicate key silently overrode the earlier value.
  • The exploit: a push option value containing ; let an attacker inject extra fields into X-Stat, override the execution context of the push, escape the hook sandbox, and run commands.
  • Blast radius: RCE on GHES (full server); on github.com, cross-tenant read of shared storage.
  • The lesson: never build a structured internal message by string-concatenating untrusted values. Use a real encoding with length-prefixing or strict escaping, and validate on the parsing side.

Prerequisites

  • Familiarity with git push and roughly what a server-side hook is.
  • A basic mental model of a service passing a request to another internal service via HTTP headers.
  • No knowledge of GitHub internals required; the shape generalizes.

Push options: the feature nobody thinks about

Git has a little-used feature called push options. Since Git 2.10 you can attach arbitrary strings to a push:

git push -o ci.skip -o deploy.env=staging origin main

The server receives those -o values and can act on them. Platforms use them for things like skipping CI, selecting a deploy target, or tagging a push. They are, by design, attacker-controlled: any user who can push to any repository can send any push option string they like. That is the untrusted input.

Nothing wrong with the feature. The wrong turn is what happened to those strings next.

The internal header: X-Stat

GitHub's push pipeline is not one process. A front-end service receives the push and hands work to internal services that do the heavy lifting (running hooks, writing objects). The context for that work, things like which repository, which user, which execution environment, travels between components in an internal HTTP header the research calls X-Stat.

X-Stat is a flat string of key=value pairs separated by semicolons:

X-Stat: repo=octocat/hello;user=42;env=sandbox;hooks=restricted

The receiving service parses it the obvious way: split on ;, split each piece on =, put it in a map. And here is the fatal detail, the one to circle in red:

If a key appears twice, the later value silently overrides the earlier one. Last write wins.

That parsing choice is common and feels harmless. It is the same behavior you get from naive query-string parsing, from Object.fromEntries, from a Go map you fill in a loop. It becomes a vulnerability the moment an attacker can inject a ; into a value that lands in this header.

Chaining it into code execution

Follow the data. The push option value is user-controlled. It gets concatenated into X-Stat. The value can contain a ;. Therefore the attacker can inject new fields.

{
  "mode": "flow",
  "title": "From push option to injected field",
  "steps": [
    { "label": "git push -o \"tag=x;env=privileged\"", "icon": "branch" },
    { "label": "Front-end concatenates the value into X-Stat", "icon": "box" },
    { "label": "X-Stat: ...;tag=x;env=privileged", "icon": "net" },
    { "label": "Downstream splits on ; -> env=privileged wins (last write)", "icon": "cpu" },
    { "label": "Push runs in an environment the attacker chose", "icon": "lock" }
  ]
}

A legitimate X-Stat might end with env=sandbox;hooks=restricted. By injecting ;env=privileged;hooks=unrestricted through a push option, the attacker appends duplicate keys. Last-write-wins means their values override the trusted ones set earlier in the string. The push is now processed with an execution context the attacker specified rather than the one the front-end intended.

From there the research chained several injected fields to override the environment the push ran in, bypass the sandbox that normally constrains server-side hook execution, and ultimately execute arbitrary commands on the backend. A server-side hook running your command, with the sandbox disabled, is game over.

The conceptual exploit is almost boring in how clean it is:

# Conceptual shape, not a working payload.
# The value carries a semicolon, so it becomes multiple X-Stat fields downstream.
git push -o "note=hi;env=privileged;hooks=unrestricted" origin main

No memory corruption. No race. Just a string that means one thing to the service that builds it and another thing to the service that parses it.

Why the blast radius was so different on GHES vs github.com

Same bug, two very different consequences, and the difference is architecture.

  • GitHub Enterprise Server is single-tenant: one organization's instance. RCE there is total compromise of that instance: every hosted repo, every secret, every credential on the box. Bad, but contained to the one customer who runs it.
  • github.com is multi-tenant on shared backend infrastructure. Code execution on a shared storage node is not scoped to the attacker's repositories. Wiz demonstrated cross-tenant read: from one foothold, the ability to read repositories belonging to unrelated organizations sharing that node.

This is the recurring tax of multi-tenancy. A bug that would be "one customer's problem" in an isolated deployment becomes "everyone on the shared node" when the tenancy boundary is logical rather than physical. It is the same lesson the industry keeps relearning, and a good argument for defense in depth around shared infrastructure even when the front-door auth is solid.

To GitHub's credit, the response was fast: reported and fixed on github.com the same day (March 4), CVE assigned March 10 with the GHES patch, coordinated public disclosure April 28. Their investigation found no exploitation beyond the researchers' own tests and no customer data compromised.

The bug you probably have

Strip away GitHub and you are left with a pattern that shows up everywhere internal services talk to each other:

  1. A trusted service collects some context and untrusted user input.
  2. It serializes both into a flat, delimited string: an HTTP header, a cookie, a log line, a message-queue field, a cache key.
  3. A downstream service parses that string back into structured data, trusting the fields because they came from an internal source.

Every step feels safe in isolation. The vulnerability is in the seams. If the untrusted input can contain the delimiter, it can forge fields, and last-write-wins parsing hands the attacker override power for free.

You have seen relatives of this bug before: HTTP request smuggling (front-end and back-end disagree on where a request ends), CRLF header injection (a newline in user input forges a new header), log injection (a newline forges a fake log entry). CVE-2026-3854 is the internal-service version. The delimiter is ; instead of CRLF, and the trust boundary is between your own services rather than at the edge, which is exactly why it slips past review: "it's an internal header, the values are ours." Some of them were not.

How to not ship this

Concrete defenses, roughly in order of how much they help:

1. Do not build structured data by concatenating strings. If you need to pass fields between services, use a serialization format that cannot be forged by the contents of a value: JSON with proper encoding, protobuf, or at minimum a length-prefixed format. A ;-joined string is a footgun the moment any value is attacker-influenced.

2. Sanitize untrusted input at the boundary where it enters the structured context. The fix here is to reject or escape delimiter characters in push option values before they can reach X-Stat. Validate on the way in, not just on the way out.

3. On the parsing side, reject duplicates instead of last-write-wins. If a key appears twice in a security-relevant header, that is not a value to overwrite, it is an anomaly to reject. Fail closed. Duplicate-key-means-error would have neutralized this exploit even with the injection present.

4. Do not trust internal headers as authenticated context. "It came from our front-end" is not integrity. If a downstream service makes security decisions from X-Stat, that header needs to be tamper-evident (signed) or reconstructed from a trusted source, not parsed from a string that untrusted input flowed into.

5. Sandbox like it will be escaped. The final step of the exploit was escaping the hook sandbox. Sandboxes are a real layer, but they are a layer, not a guarantee. Assume code execution can happen and limit what the resulting process can reach.

If you run GitHub Enterprise Server

Patch. The fix landed in 3.14.24, 3.15.19, 3.16.15, 3.17.12, 3.18.6, and 3.19.3, and with 88% of instances unpatched at disclosure, the odds that a given GHES box is still exposed are not comforting. This is authenticated RCE, so the risk is proportional to how many people can push to any repository on your instance, which for most organizations is "everyone."

And regardless of what you run: go find your own X-Stat. Somewhere in your system, a service is building a delimited string from a trusted value and an untrusted one, and another service is parsing it back with last-write-wins. That is the bug. GitHub's was in a push pipeline. Yours might be in a cache key or a log aggregator. The delimiter is always waiting in the value.

Published: 2026-07-20|Last updated: 2026-07-20T09:30:00Z

Found an issue?

Also worth your time on this topic