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 pushsupports "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-Statheader carried security-critical fields askey=valuepairs 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 intoX-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 pushand 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:
- A trusted service collects some context and untrusted user input.
- It serializes both into a flat, delimited string: an HTTP header, a cookie, a log line, a message-queue field, a cache key.
- 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.
We earn commissions when you shop through the links below.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?
Related Posts
Also worth your time on this topic
The pwn request just got harder: what actions/checkout v7 changes, and what it does not
GitHub is backporting a fork-checkout block to actions/checkout, with enforcement on July 20, 2026. Here is what a pwn request actually is, what the change stops, and the three ways your pipeline is still exposed after you upgrade.
Complete CI/CD Pipeline with GitHub Actions
Hands-on lab: build a production CI/CD pipeline with GitHub Actions, including tests, security scanning, container builds, and automated deployment.
90 minutes
Secrets Management
How do you securely manage secrets (passwords, API keys, certificates) in a DevOps environment?
mid