PDF import: the past-the-window /Encrypt fallback matches escaped names

The fallback searched for the literal bytes `/Encrypt`, so a long trailer
with the key written as `/Encr#79pt` (or any other #-escaped spelling) got
past it. It now finds the last name token that decodes to `Encrypt`, with
the lexer's own name decoding, and checks whether that token ends past the
window. It is still one linear pass per file. Longer names that only start
with `Encrypt` (`/Encrypted`) no longer count.
This commit is contained in:
becauseno7
2026-10-31 22:07:29 +06:00
parent 4319df2e59
commit cd6c56b78d
3 changed files with 52 additions and 20 deletions
+18 -11
View File
@@ -275,19 +275,26 @@ fn hexv(c: u8) -> u8 {
}
}
fn decode_name(w: &[u8]) -> String {
let mut out = vec![];
/// The bytes of a name token (after its `/`), with `#xx` escapes decoded.
pub(crate) fn name_bytes(w: &[u8]) -> impl Iterator<Item = u8> {
let mut i = 0;
while i < w.len() {
if w[i] == b'#' && w.get(i + 1).is_some_and(u8::is_ascii_hexdigit) && w.get(i + 2).is_some_and(u8::is_ascii_hexdigit) {
out.push((hexv(w[i + 1]) << 4) | hexv(w[i + 2]));
i += 3;
} else {
out.push(w[i]);
i += 1;
std::iter::from_fn(move || {
let c = *w.get(i)?;
match (c, w.get(i + 1), w.get(i + 2)) {
(b'#', Some(&hi), Some(&lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() => {
i += 3;
Some((hexv(hi) << 4) | hexv(lo))
}
_ => {
i += 1;
Some(c)
}
}
}
String::from_utf8_lossy(&out).into_owned()
})
}
fn decode_name(w: &[u8]) -> String {
String::from_utf8_lossy(&name_bytes(w).collect::<Vec<_>>()).into_owned()
}
pub fn parse_num(w: &[u8]) -> Option<f64> {
+18 -9
View File
@@ -11,7 +11,7 @@ use craft_pdf::syntax::object::{Dict as PdfDict, MaybeRef, Name, ObjRef, Object,
use craft_pdf::syntax::xref::XRef;
use crate::Error;
use crate::object::{Dict, File, Lexer, Obj};
use crate::object::{Dict, File, Lexer, Obj, is_delim, is_white, name_bytes};
const MAX_OBJECTS: usize = 100_000;
const MAX_VALUES: usize = 1_000_000;
@@ -19,11 +19,12 @@ const MAX_DEPTH: usize = 64;
const MAX_BYTES: usize = 512 * 1024 * 1024;
/// How far after each `trailer` marker the encryption pre-scan lexes. A classic trailer
/// dictionary is a few hundred bytes. If the lexer reaches the window's end, the dictionary
/// may start or continue past it, so the file is refused when `/Encrypt` appears anywhere past
/// the window. This fallback is conservative: it refuses on any `/Encrypt` text there, which
/// in practice only encrypted files contain. The pre-scan stays linear in file size, at most
/// `MAX_OBJECTS` windows (about 400 MB) and one search for `/Encrypt`, even when a marker is
/// followed by an unterminated string or dictionary the lexer would follow to the end.
/// may start or continue past it, so the file is refused when a name token that decodes to
/// `Encrypt`, in any spelling (`/Encrypt`, `/Encr#79pt`), ends past the window. This fallback
/// is conservative: it refuses on any such name there, which in practice only encrypted files
/// contain. The pre-scan stays linear in file size, at most `MAX_OBJECTS` windows (about 400 MB)
/// and one search for the name, even when a marker is followed by an unterminated string or
/// dictionary the lexer would follow to the end.
const TRAILER_WINDOW: usize = 4 * 1024;
fn limit(what: &str) -> Error {
@@ -50,7 +51,14 @@ fn open_inner(bytes: &[u8]) -> Result<File, Error> {
// Preserve the app's refusal of encrypted PDFs, including empty-user-password files.
// Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below
// are read by the shared parser. This does not parse document objects a second time.
let last_encrypt = LazyCell::new(|| bytes.windows(8).rposition(|word| word == b"/Encrypt"));
// Where the last name token decoding to `Encrypt` (escaped or not) ends, searched at most once.
// Each token scan stops at the next `/`, so the search is linear.
let last_encrypt = LazyCell::new(|| {
bytes.iter().enumerate().rev().filter(|&(_, &c)| c == b'/').find_map(|(at, _)| {
let token = bytes.get(at + 1..).unwrap_or_default().split(|&c| is_white(c) || is_delim(c)).next().unwrap_or_default();
name_bytes(token).eq(*b"Encrypt").then_some(at + 1 + token.len())
})
});
let mut markers = 0;
for (offset, word) in bytes.windows(7).enumerate() {
if word == b"trailer" {
@@ -64,8 +72,9 @@ fn open_inner(bytes: &[u8]) -> Result<File, Error> {
let mut lexer = Lexer::new(window, 0);
let encrypt = matches!(lexer.next(), Some(Obj::Dict(dict)) if dict.contains_key("Encrypt"));
// Whatever it returned, a lexer at the window's end (one past it after an unterminated
// hex string) may have stopped before the dictionary started or partway through it.
let cut = lexer.pos >= window.len() && last_encrypt.is_some_and(|at| at.saturating_add(8) > end);
// hex string) may have stopped before the dictionary started or partway through it, so
// an `Encrypt` name in any spelling that ends past the window refuses the file.
let cut = lexer.pos >= window.len() && last_encrypt.is_some_and(|name_end| name_end > end);
if encrypt || cut {
return Err(Error::Encrypted);
}
+16
View File
@@ -109,6 +109,22 @@ fn an_encrypt_entry_past_the_trailer_window_is_refused() {
assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted));
}
#[test]
fn an_escaped_encrypt_entry_past_the_trailer_window_is_refused() {
// A name may write any byte as `#` and two hex digits, so these keys all read as /Encrypt and
// the past-the-window fallback refuses them. The decoys decode to other names and still open.
let plain = String::from_utf8(document("", &[])).unwrap();
let doc = parse(plain.as_bytes()).unwrap();
let filler = "x".repeat(8 * 1024);
let trailer = |key: &str| plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /Filler ({filler}) {key} 9 0 R >>"));
for key in ["/Encr#79pt", "/#45#6E#63rypt", "/E#6e#63rypt"] {
assert_eq!(parse(trailer(key).as_bytes()), Err(Error::Encrypted), "{key}");
}
for decoy in ["/Encrypted", "/Encr#7Apt"] {
assert_eq!(parse(trailer(decoy).as_bytes()).as_ref(), Ok(&doc), "{decoy}");
}
}
#[test]
fn an_encrypt_entry_after_a_window_cut_before_the_trailer_dictionary_is_refused() {
// The 4 KiB pre-scan window ends in the whitespace before the trailer dictionary (the lexer