Back to blog

CVE-2026-3854: how a single git push turned GitHub's internal pipeline into a global attack surface

· · 14 min read
CVE-2026-3854: how a single git push turned GitHub's internal pipeline into a global attack surface

On April 28, 2026, the Wiz Research team published a full breakdown of a vulnerability they had reported to GitHub almost two months earlier. The verdict was harsh: a single git push command, hand-crafted with a standard git client, was enough to achieve remote code execution (RCE) on GitHub.com's backend servers and on any unpatched GitHub Enterprise Server (GHES) instance. The vulnerability was assigned CVE-2026-3854 with a CVSS score of 8.7.

This article is a two-layered breakdown. The first part is written for non-technical readers who need to decide quickly whether they have anything to do and what exactly. The second part dissects the exploitation chain the way Wiz reconstructed it, plus a discussion of what this class of bug means for any modern multi-service architecture. At the end, we look at how the Pragma Core platform addresses exactly the kind of problems that made this breach possible.


Part I. Executive breakdown

What happened

Researchers at Wiz uncovered a command injection issue in how GitHub processes git push operations internally. Specifically, the values a user can pass via git push -o (push options) were not properly sanitized before being embedded in an internal header called X-Stat. That header is consumed by downstream services to make security decisions, such as whether to run hooks inside a sandbox or directly, which directory to look in for hook scripts, and what limits to apply to files.

Because the delimiter used in X-Stat (the ; character) could also appear in user input, an attacker could inject extra fields into the header and effectively overwrite the server's security configuration during their own push. By chaining three such injections, the attacker ended up executing arbitrary code on the server processing the push, running as the platform's git user.

Who is affected

Component Status
GitHub.com Patch deployed within 2 hours of the report. No user action required.
GitHub Enterprise Cloud (all variants) Mitigated by GitHub.
GitHub Enterprise Server (GHES) <= 3.19.1 Vulnerable. Urgent upgrade required.
Patched GHES versions 3.14.25, 3.15.20, 3.16.16, 3.17.13, 3.18.7, 3.19.4, 3.20.0 or later

The number that should worry anyone running a self-hosted GHES: at the time of Wiz's publication, 88% of instances were still unpatched. That means thousands of organizations effectively have their entire intellectual property and all their internal secrets exposed to an exploit that only requires push access to a repository, including a repository the attacker creates themselves.

Why this matters beyond GitHub

The bug itself is elegant and relatively easy to understand once you see the diagram. What matters more is the pattern. Many organizations run architectures where services written in different languages (Go, Ruby, Rust, C) pass context to each other through internal headers or metadata structures. Each service makes reasonable assumptions about what the input looks like. When those assumptions don't line up, you get exactly this kind of injection. GitHub's git push pipeline is an almost textbook illustration of the problem: babeld assumed push option values were safe to embed verbatim, gitrpcd assumed everything in the X-Stat header was set by a trusted source, and the pre-receive hook binary assumed the rails_env environment variable could only be production in production.

Each assumption, taken individually, was plausible. Combined, they opened a path to RCE.

Recommended actions

  1. For self-hosted GHES: upgrade immediately to one of the patched versions. There is no documented workaround. If the upgrade can't happen in the next few days, isolate the instance at the network level and reduce push permissions until the patch lands.
  2. Check your audit logs. GitHub recommends inspecting /var/log/github-audit.log for push operations containing the ; character in push options. Any historical entry of that kind deserves investigation as a possible exploitation attempt.
  3. Re-evaluate push access policies. The vulnerability could be exploited by any authenticated user with push permission on a repository, including a repo the attacker just created. If your instance allows free user registration, your attack surface was effectively the full size of your user base.
  4. Look at the rest of your SDLC. If you run other multi-service platforms (CI/CD systems, registries, build pipelines), apply the same question: what user-controlled input ends up in internal headers or metadata structures without strict sanitization?

Part II. Technical breakdown

The architecture of GitHub's git push pipeline

When a user runs git push over SSH against GitHub, the request flows through a few key components, each written in a different language:

  1. babeld: git proxy and entry point for all SSH-based git operations. It receives the SSH connection, forwards authentication to gitauth, and constructs the internal X-Stat header carrying the security metadata applicable to the session.
  2. gitauth: internal authentication service. It verifies credentials, checks repository permissions, and returns to babeld the policies that apply (file size limits, branch naming rules, and so on).
  3. gitrpcd: internal RPC server. It receives the request from babeld, parses X-Stat, and sets up the environment for downstream processes. It performs no authentication of its own. It trusts babeld completely.
  4. Pre-receive hook: a compiled Go binary that enforces security policies before a push is accepted. It checks limits, rules, LFS integrity, and runs admin-defined custom hooks.

The critical link between these components is the X-Stat header. It carries configuration fields as key=value pairs separated by semicolons (;). Internal services parse it by splitting on ; and populating a map. And here lies the fatal detail: the map uses last-write-wins semantics. If a key appears twice, the later value silently overrides the earlier one, with no warning.

The vulnerability: X-Stat field injection

Push options are a standard feature of the git protocol. Users can send them with git push -o key=value. babeld encodes them in the X-Stat header as push_option_0, push_option_1, and so on, alongside push_option_count.

The problem: babeld copies push option values directly into X-Stat without escaping the ; character. Because ; is the header's field delimiter, any push option containing this character breaks out of its own field and creates new, attacker-controlled fields.

Concretely, a push option of the form x;large_blob_rejection_enabled=bool:false ended up in the header like this:

X-Stat: ...; large_blob_rejection_enabled=bool:true; ...;
  push_option_0=x;large_blob_rejection_enabled=bool:false;
  push_option_count=1; ...

After parsing, thanks to last-write-wins semantics, the resulting map looked like:

push_option_0                = x
large_blob_rejection_enabled = bool:false   <-- the injected value wins
push_option_count            = 1

Wiz confirmed this behavior both through binary analysis and via packet capture on a live GHES instance. Injected fields actually appeared in X-Stat and overrode the values set by babeld.

Security-relevant fields

Through binary analysis of the pre-receive hook, Wiz identified the injectable fields with the highest impact:

Field Purpose
rails_env Controls hook execution path (sandbox vs. direct exec)
custom_hooks_dir Base directory for hook script lookup
repo_pre_receive_hooks JSON definitions of hooks to execute
large_blob_rejection_enabled Enforces file size limits
reject_sha_like_refs Blocks SHA-like branch names
user_operator_mode Enables internal debug output

The first three are what matters most. Combined, they lead to RCE.

The escalation chain to RCE

GHES supports custom pre-receive hooks, admin-defined scripts that run before a push is accepted. Reverse engineering revealed that the pre-receive binary has two execution paths, controlled entirely by the value of rails_env:

The only thing separating these two paths is the value of rails_env. And that value is injectable.

The full chain, in three steps:

  1. Sandbox bypass: inject a non-production rails_env value to switch from the sandboxed production path to the unsandboxed one.
  2. Hook directory redirect: inject custom_hooks_dir to control where the binary looks up hook scripts.
  3. Hook injection with path traversal: inject repo_pre_receive_hooks with a crafted entry whose script field contains a path traversal sequence. The binary's path resolution joins the attacker-controlled base directory with the traversal payload and resolves to an arbitrary binary on the filesystem.

The non-production path then executes the resolved binary directly, no arguments, no sandbox, as the git user:

git push -o '<injected fields>' origin master
remote: uid=500(git) gid=500(git) groups=500(git)   <-- RCE as the git user
To github.com:user/repo.git
   abc1234..def5678  master -> master

Jumping from GHES to GitHub.com

Initial tests against GitHub.com produced no output. Hooks simply did not execute on the custom hooks path. Wiz injected user_operator_mode=bool:true to enable debug output on both platforms and compared the trace.

The difference: GitHub.com had a boolean flag in X-Stat that controlled whether the server ran in "enterprise mode". On GHES, the flag defaulted to true, so the custom hooks path was always active. On GitHub.com it defaulted to false, so under normal conditions that path was never reached.

But the flag was carried in X-Stat too. So it was injectable through the same mechanism. With one extra injected field, the full exploitation chain worked on GitHub.com as well.

Cross-tenant impact on GitHub.com

On GHES, RCE as the git user already means full instance compromise. On GitHub.com, things are worse. GitHub.com is multi-tenant. Repositories belonging to millions of organizations and users are stored on shared backend infrastructure. When Wiz achieved code execution on GitHub.com, they landed on one such shared storage node, running as the git user, which by design has read access to every repository hosted on that node.

Wiz explicitly states they did not access the contents of other tenants' repositories. Validation was done only on their own test accounts, by enumerating index entries, which confirmed that the git user's filesystem permissions would have allowed reading any repository on the node, regardless of owner.

Timeline

Date Event
2026-03-04 Wiz Research discovers the push option injection vulnerability.
2026-03-04 RCE confirmed on GHES 3.19.1.
2026-03-04 Report sent to GitHub.
2026-03-04 GitHub acknowledges receipt.
2026-03-04 Fix deployed on GitHub.com (under 2 hours from report).
2026-03-10 CVE-2026-3854 assigned, CVSS 8.7.
2026-03-10 GHES patch released.
2026-04-28 Public disclosure.

A note on the discovery methodology

One detail worth calling out separately: Wiz states they used AI-augmented reverse engineering tooling (specifically IDA MCP) to analyze GitHub's compiled binaries and reconstruct the internal protocol. The team characterizes this CVE as "one of the first critical vulnerabilities discovered in closed-source binaries using AI". Whether or not the claim holds up over time, the message to the AppSec community is clear: the cost barrier for closed-source code auditing is dropping fast, and multi-service pipelines are now firmly in scope as practical targets for researchers with moderate resources.


What we should learn from CVE-2026-3854

A few patterns show up repeatedly in this kind of bug, and they're worth investigating proactively in any codebase:

  1. Internal protocols based on delimiters, without strict escaping. If a character used as a delimiter can also appear in user input, with no escape or encoding, it's only a matter of time until someone exploits it.
  2. Last-write-wins on parsed structures. Maps that silently overwrite duplicate values are attacker-friendly. Parsing should explicitly reject duplicate keys, or at least log and alert on them.
  3. Non-production code paths shipped in production binaries. The direct, unsandboxed hooks path existed in the binary that ships to production. Its presence, gated by a single variable, was an invitation. Code paths disabled by a flag remain attack surface until they are removed at build time.
  4. Missing path traversal validation on internal metadata inputs. The moment a field from an internal header can influence the resolution of an executable path, validation must happen at the point of execution, not at the point where the value is set.
  5. Implicit trust boundaries between services. gitrpcd trusted babeld completely. That trust was documented nowhere and was not technically validated. Inter-service trust should be explicit and verified, ideally cryptographically.

How Pragma Core addresses this class of problem

Pragma Core is an application security platform built specifically for the class of issues that made CVE-2026-3854 possible. We're talking about vulnerabilities that classical scanners don't surface, because they're not about a single badly written function. They're about how input flows across multiple services and components, each carrying its own assumptions about what the others are sending it.

Here are the concrete connections between what the platform does and the bug class this CVE represents:

Autonomous AI agents for attack chain investigation

The autonomous agents in Pragma Core don't stop at "we found an unescaped input". Their reasoning over attack chains tries to answer questions like: "If the value of this parameter ends up in an internal header, where else is the header parsed? Which of the services consuming it have higher privileges than the entry point?". That's exactly the kind of thinking that led to the discovery of CVE-2026-3854.

Interactive call graphs with vulnerability overlay

Wiz's reverse engineering manually reconstructed the call graph between babeld, gitauth, gitrpcd, and the pre-receive hook. Pragma Core generates call graphs automatically for any connected repository, visualizing how classes, functions, and calls connect across the codebase. Overlaying identified vulnerabilities on top of these graphs lets teams see at a glance where untrusted input propagates and where it crosses trust boundaries.

Continuous tracking of third-party packages

Many organizations run libraries and components that propagate the same kinds of bugs into their own pipelines. Pragma Core tracks every third-party package across all connected repositories, surfacing vulnerable versions with CVSS scores and fixed upgrade paths. For a CVE like this one, the practical effect is that an organization self-hosting GHES with internal tooling that depends on the affected version gets notified the moment the CVE lands in the catalog, with no manual monitoring required.

Full SBOM per repository

Pragma Core generates a complete inventory of libraries, versions, licenses, and package URLs for every repository, exportable as CycloneDX JSON. In the context of a breach like CVE-2026-3854, that kind of inventory is essential to quickly answer "what installations do we have, and which version is each running?", which, in 88% of the cases reported by Wiz, was a question teams couldn't answer fast enough.

Human-guided AppSec investigations

The expert-led research module in Pragma Core is built precisely for the type of investigation Wiz did here. An AppSec operator drives deeper analysis, supported by autonomous agents, automated scans, and the platform context already available in the workspace. The focus is on what your team feels is fragile: sensitive features, complex code paths, business-critical components. Not a separate black-box engagement, but a natural extension of your existing subscription, leveraging your existing repository coverage and findings. That's the same way of working that, applied systematically to a pipeline like GitHub's, would have raised flags on the X-Stat injection long before anyone reached the full exploitation chain.

Native repository integration

The platform connects to GitHub, GitLab, and Azure DevOps in minutes. For an organization self-hosting GHES, integration is direct: continuous scanning starts immediately, and the findings generated are contextualized with repository, branch, and commit.


Closing thoughts

CVE-2026-3854 is not, fundamentally, a bug about git push, and it's not a bug about GitHub. It's a bug about what happens when user-controlled input reaches internal protocols based on delimiters, when services make implicit assumptions about each other without verification, and when "non-production" code paths remain present in production binaries.

The same patterns are present in many modern architectures. The difference between an organization that reads this article as a curiosity and one that uses it as a trigger for an audit usually comes down to how mature the application security function is and how much visibility teams have into their own code.

For organizations that want to move from "we scan and report" to "we systematically investigate what's fragile", Pragma Core was built to make that step. You can reach us directly at pragma-core.com for a demo or to talk about how the platform could fit into your security pipeline.


Sources

Related posts
CVE-2026-74820: how an unsanitized ORDER BY clause turned ServiceNow's AI Platform into an unauthenticated database backdoor
Sep 18, 2026
CVE-2026-85978: how one path normalization mismatch turned Akana's admin console into unauthenticated remote code execution
Sep 9, 2026
CVE-2026-78174: how an unredacted session token in a diagnostic log turned a low-privileged WatchGuard Dimension admin into super admin
Sep 1, 2026

Start securing your codebase today

Connect your repositories and let AI agents handle continuous scanning, research, and triage.

Have questions? Get in touch →