# IR Playbook — CVE-2026-60004 (Gitea `diffpatch` → RCE) Incident-response guide. The commands assume the official deployment's layout (the `/data` volume, OS user `git`). Dates and figures: `designs/FACTS.md`. ## Phase 0 — open the case Trigger: any signal from §4 of `remediation.md`, or KEV/BOD as a deadline obligation (federal due date **2026-08-28**). Preserve before touching anything. An order that does not destroy evidence. The container name and volume name are **yours**, so they are parameters here; in our lab they were `gitea-vuln` and `gv_data`, and a reader who copies these lines verbatim would be typing names that only exist in our repository: ```bash GITEA_CONTAINER=gitea # your container name GITEA_VOLUME=gv_data # the volume that holds /data # 1) volume snapshot (do not delete, do not "clean up" yet) docker stop "$GITEA_CONTAINER" && docker run --rm -v "$GITEA_VOLUME":/data alpine \ tar czf - -C /data . > ir-$(date -u +%Y%m%dT%H%M%SZ)-gitea-data.tgz sha256sum ir-*.tgz # chain of custody # 2) LIVE process and network dump (it is lost once you stop it) docker exec "$GITEA_CONTAINER" sh -c 'ps -o pid,ppid,user,args; cat /proc/net/tcp' > ir-processes.txt # 3) access log + application log (note: in the stock image no request log is written to # /data/gitea/log - see evidence/app-log-visibility.txt - so the container stdout is the log) docker logs --since 72h "$GITEA_CONTAINER" > ir-gitea.log 2>&1 # 4) the diffpatch temporaries: the most volatile evidence, goes FIRST docker exec "$GITEA_CONTAINER" sh -c 'find /data/gitea/tmp/local-repo -maxdepth 3 2>/dev/null' > ir-tmp.txt ``` **Do not** restart the container hoping to "clean up": the hook lives in `/data`, which persists. ## Phase 1 — detect whether execution happened The decisive question: *did a hook appear and get executed?* Not "was the patch sent?" — the patch arrives the same way on patched versions (§9 of FACTS). ```bash # A) hook materialized in the diffpatch temporary (on a clean instance: nothing) docker exec gitea sh -c 'find /data/gitea/tmp -name "post-index-change" -o -name "hooks" -type d 2>/dev/null' # B) live hooks in real repositories that are not a user's docker exec gitea sh -c 'find /data/git -path "*/hooks/*" ! -name "*.sample" -type f 2>/dev/null' # C) service files that nobody put there docker exec gitea sh -c 'ls -la /data/gitea/ ; find /data/gitea -newermt "-14 days" -type f 2>/dev/null | head -40' # D) branches/commits authored by the service (the vendor's PoC returns output through a branch) docker exec --user git gitea sh -c 'cd /data/gitea && /app/gitea/gitea admin user list' ``` Pointers specific to this chain, measured here: a `/bin/sh hooks/post-index-change 0 0` process whose parent is **`git write-tree`**, with cwd `/data/gitea/tmp/local-repo/upload.git` and `GIT_DIR=.`. ## Phase 2 — assess (real impact, not theoretical) If execution happened, it runs as the OS user `git` (uid 1000 in the official image). Measured with that primitive: read of `app.ini` (**3** lines of secrets), read of credentials on disk, **write** into `/data/gitea`, and listing of every repository. Treat as compromised, and rotate **in this order**: 1. `SECRET_KEY`, `INTERNAL_TOKEN`, `JWT_SECRET` (we read all three from `app.ini` in the lab), and the `DATABASE` section's credentials, which we did **not** see: our lab instance was SQLite with an empty `PASSWD`. 2. Session/API tokens and personal access tokens of **all** users — reachable **if** the `DATABASE` credentials in your `app.ini` work from the app host. In our lab there was no database credential to read (SQLite, empty `PASSWD`); the only DB-style credential we read was `DB_PASSWORD` from a secrets file **we planted ourselves**. We did not query any database. 3. OAuth credentials / integrations / outbound webhooks. 4. SSH keys deployed by Gitea (the container has `/data/git/.ssh`). 5. Any secret on the *host* mounted into the container or reachable from its network. ## Phase 3 — eradicate 1. Rebuild from a **1.27.1+** image — do not "hot-patch" a host that could have had arbitrary write into its own service directory. 2. Review every non-`.sample` `hooks/*` under `/data/git/repositories/**`; each one is an attacker artifact until proven otherwise. 3. Review RAM/persistence: an orphan `git` process or an added `authorized_keys`. ## Phase 4 — recover and verify - `curl -s http:///api/v1/version` → 1.27.1 or higher. - Repeat the A/B check from `lab/` and `exploit/` **against your instance** (pre-authorized, your infrastructure only): the negative must be non-vacuous — observe that the payload **arrives** and does not execute, not just that "nothing happened". - Restore users/repos from a clean snapshot, not from the compromised one. ## Phase 5 — lessons - **The prerequisite was an ordinary account.** With open registration, an anonymous user creates one. Review what other functions in your inventory depend on "the attacker needs an account". - **The status-code log is worthless here**: the exploit answered 201/201 because the hook's return does not propagate into the `diffpatch` response. Add it to the list of "signals my SIEM is not capturing". - **A CVE's patch does not define detection.** The fix separates the worktree from `$GIT_DIR` and does not touch diff paths: the content signature is fragile, behavior detection is required.