Skip to content

Encrypt the byte order mark along with the text it marks (#460) - #494

Open
jafin wants to merge 2 commits into
ststeiger:masterfrom
jafin:upstream-460-encrypted-byte-order-mark
Open

Encrypt the byte order mark along with the text it marks (#460)#494
jafin wants to merge 2 commits into
ststeiger:masterfrom
jafin:upstream-460-encrypted-byte-order-mark

Conversation

@jafin

@jafin jafin commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #460, reproduced with the exact SecureDocument settings from that issue at both 40-bit and 128-bit.

What happens

A UTF-16BE text string begins with a byte order mark, and that mark belongs to the value of the string rather than to its syntax, so an encrypted document has to encrypt it along with the text. PdfSharpCore spells it out in front of the ciphertext:

/Title <FEFF735D4629283C479E9E9C2625C92372483672>
        ^^^^ plaintext, only what follows is encrypted

A conforming reader decrypts the whole string. It takes FE FF for two bytes of text, and from there the keystream is two bytes out of step, so everything after it comes out as noise. That is why the title and author of a protected document are unreadable in Acrobat and in Firefox.

Only Title, Author, Subject, Keywords and Creator are affected: PdfDocumentInformation writes those with PdfStringEncoding.Unicode, while Producer is set through the two-argument SetString and goes out as a plain literal. That is also why PdfSecurity.CreateAndReadPasswordProtectedPdf, which asserts on Producer, never saw this.

Setting DocumentSecurityLevel.None, suggested in the thread, avoids it only by removing the encryption.

The reader has the same fault in reverse

Lexer.ScanHexadecimalString strips the mark before anything is decrypted, so a string encrypted the way the specification asks for it arrives as bytes that no longer say what they are. A document from any other producer reads back as "\u00FE\u00FF\u0000T\u0000h\u0000e...". Nothing showed, because a reader that shares the writer's mistake reads the writer's output back perfectly.

The two halves are one fault and have to move together: with only the writer corrected, the existing CreateAndReadPasswordProtectedPdf fails on its outline title. That was verified rather than assumed.

The fix

  • PdfEncoders.FormatStringLiteral puts the mark into the bytes before they are encrypted. An unencrypted document is left byte for byte what it was, since < + hex(FEFF ‖ bytes) is the same text as <FEFF + hex(bytes).
  • PdfString.EncryptionValue and PdfStringObject.EncryptionValue look for the mark after decrypting, where it can now be seen, and correct the encoding of the string to match. PdfStringObject also gains the Unicode handling its TODO asked for.

Documents written before this still open: their mark is outside the ciphertext, the lexer strips it as it always did, and what is decrypted then carries no mark to find. There is a test for that.

Only RC4 is affected in practice, since that is all the writer produces; the AES path is read-only and goes through the same corrected code.

How this was checked

Not by reading the output back — that is what hid the fault. PdfSharpCore.Test/Security/StandardSecurity.cs is an independent implementation of the standard security handler (ISO 32000-1 clause 7.6, algorithms 1, 2, 4 and 5). It checks its own key derivation against the /U entry of the document it is given, so a test that cannot agree with the document fails rather than comparing noise to noise. With the key confirmed that way, /Producer decrypted perfectly while the Unicode strings did not, which is what isolated this to Unicode strings rather than to the key.

EncryptedTextStringTests covers, at both security levels:

  • a conforming reader can decrypt the properties
  • the mark is no longer written outside the ciphertext
  • the properties are read back
  • a document written before the fix still opens
  • an unencrypted document spells the mark out exactly as before

Without the change 4 of the 9 fail. With only the writer corrected, the read-back tests and the pre-existing security test fail.

Test run on net8.0: 35 passed, 5 failed, 1 skipped. The five failures are the XTextFormatterTest rendering comparisons, which fail identically on an unmodified master here because they shell out to Ghostscript (gswin64c.exe, exit 127) and it is not installed on this machine. Baseline on master is 26 passed with the same 5 failures, so this adds 9 passing tests and changes nothing else.

Note for reviewers

This changes the bytes of every encrypted document the library writes. Old documents still open, but anything downstream pinning the expected output of an encrypted PDF will see a diff.

jafin added 2 commits July 28, 2026 08:16
A UTF-16BE text string begins with a byte order mark, and that mark is
part of the value of the string rather than part of its syntax, so an
encrypted document has to encrypt it with the text. PdfSharpCore spelled
it out in front of the ciphertext instead, writing <FEFF...> where the
FEFF was plain and only what followed was encrypted. A reader decrypts
the whole string, so it took the mark for two bytes of text, and with
the keystream two bytes out of step from there on the rest of the string
came out as noise. That is what makes the title and the author of a
protected document unreadable in Acrobat and in Firefox, reported in
issue ststeiger#460.

Only Title, Author, Subject, Keywords and Creator are affected.
PdfDocumentInformation writes those with PdfStringEncoding.Unicode,
while Producer is set through the two argument SetString and goes out as
a plain literal, which is why the round trip test that asserts on
Producer never saw this.

The reader made the same mistake in reverse. It strips the mark in the
lexer, before anything has been decrypted, so a string encrypted the way
the specification asks for it arrives as bytes that no longer say what
they are, and a document from any other producer reads back as noise.
Nothing showed, because a reader that shares the writer's mistake reads
the writer's output back perfectly. The two halves are one fault and
have to move together: with only the writer corrected, the existing
CreateAndReadPasswordProtectedPdf fails on its outline title.

The writer now puts the mark into the bytes before they are encrypted,
which leaves an unencrypted document byte for byte what it was, since
the same mark is written either way. The reader looks for it after
decrypting, where it can now be seen, and corrects the encoding of the
string to match what it found. PdfStringObject gains the Unicode
handling its TODO asked for. Documents written before this still open:
their mark is outside the ciphertext, the lexer strips it as it always
did, and what is decrypted then carries no mark to find.

The tests check what the writer produces against an independent
implementation of the standard security handler rather than by reading
it back, since reading it back is what hid this. That implementation
checks its own key derivation against the /U entry of the document it is
given, so a test that cannot agree with the document fails rather than
comparing noise to noise.
Moving the byte order mark into the bytes moved it into the count the
hexadecimal writer breaks lines by, which breaks every 48 bytes. A string
of more than 24 characters therefore came out with its line breaks two
bytes earlier than before. The breaks are white space inside a hexadecimal
string and mean nothing to a reader, but the claim that an unencrypted
document is left byte for byte what it was only held for strings too short
to break at all, which is exactly what the test used.

Count from the text rather than from the start of the bytes, so the mark
travels with the text without carrying the count along with it.

The test now covers a title long enough to break, and the expected string
is built by the loop as it stood before, so it states what is being
preserved rather than restating the result.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PDF document properties are scrambled on protected PDF as of version 1.3.34

1 participant