Challenge: Warmerer Up · CTF: DalCTF 2026 · Category: Forensics · Difficulty: Medium

TL;DR

A 4.9 MB “rules” PDF that should have been a few kilobytes turned out to be a Russian doll:

1
2
3
4
5
6
7
rules2.pdf
 └── 360 hidden 1×1 image XObjects, each holding a base64 chunk (@@N:…@@end)
      └── concatenate + base64-decode → encrypted ZIP (image.sif inside)
           └── password "teapot_2026" (hidden in the rules text)
                └── Singularity/Apptainer .sif container (Alpine)
                     └── embedded squashfs filesystem
                          └── /home/flag/flag.txt

Peel every layer and you get the flag.


The hook

The challenge name “Warmerer Up” and the description “What, what, the rules again again?” are doing a lot of work. There was an earlier challenge called Warmer Up that involved a rules.pdf, and this one ships a rules2.pdf. The repeated “again again” / “what what” is the author winking at you: yes, it’s the rules PDF again, but look harder this time.

So we start with a single file: rules2.pdf.

Step 1 — Trust your eyes on file size

The very first thing that should set off alarm bells is the size. This is a 3-page rules document, but the file is 4.9 MB:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ ls -la rules2.pdf
-rw-r--r-- 1 kali kali 5105265 Jun  6 23:30 rules2.pdf

$ pdfinfo rules2.pdf
Title:        rules.md
Creator:      ...HeadlessChrome/148.0.0.0...
Producer:     pdf-lib (https://github.com/Hopding/pdf-lib)
Pages:        3
Page size:    594.96 x 841.92 pts (A4)
File size:    5105265 bytes

Three pages of plain text rendered by pdf-lib from a markdown file. There is no good reason for that to be 5 MB. Something is hiding in there.

Step 2 — Where is the weight?

binwalk shows the file is wall-to-wall zlib streams — hundreds of them — which is normal-ish for a PDF, but the sheer count is unusual:

1
2
3
4
5
6
$ binwalk rules2.pdf | head
0    0x0    PDF document, version: "1.7"
70   0x46   Zlib compressed data, default compression
482  0x1E2  Zlib compressed data, default compression
748  0x2EC  Zlib compressed data, default compression
... (hundreds more) ...

Two standard forensics checks for PDFs come back empty:

1
2
3
$ pdfimages -list rules2.pdf      # no rasterized images at all
$ pdfdetach -list rules2.pdf
0 embedded files                  # no /EmbeddedFile attachments

So pdfimages says there are no images, yet the file is enormous. That contradiction is the whole challenge. Time to crack the PDF open by hand.

Step 3 — Reading the PDF object tree

Using mutool show to dump the raw objects, the document catalog (object 1) immediately stands out:

1
2
3
4
$ mutool show rules2.pdf grep | head
1 0 obj <</Type/Catalog/Lang(en)/MarkInfo<</Marked true>>/Pages 2 0 R
  /StructTreeRoot 3 0 R
  /X0 292 0 R /X1 293 0 R /X10 302 0 R ... /X359 651 0 R>>

The catalog references /X0 through /X359 — 360 XObjects (objects 292–651). This is bizarre for two reasons:

  1. XObjects normally live in a page’s /Resources, not bolted directly onto the document catalog. Putting them on the catalog means they are part of the file but never drawn on any page — perfect for hiding data.
  2. There are exactly 360 of them, which smells like chunked data rather than legitimate graphics.

Let’s look at one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ mutool show rules2.pdf 292
292 0 obj
<<
  /Type /XObject
  /Subtype /Image
  /Width 1
  /Height 1
  /ColorSpace /DeviceGray
  /BitsPerComponent 8
  /Length 13848
>>
stream
@@0:UEsDBBQACQAIAG1sw1zickA+oQU5AACwOQAJABwAaW1hZ2Uuc2lmVVQJAAPNVyBq...
...
...iGjld0i83Z7e+vLwesPP+PnOqL@@end
endstream

A “1×1 grayscale image” whose stream is 13 KB of base64 text. That is not an image — a real 1×1 grayscale pixel is one byte. This is pdfimages’s blind spot too: it tried to interpret these as images, saw the dimensions made no sense, and silently skipped them.

Two details give the game away completely:

  • The chunk is wrapped in markers: @@0: at the start and @@end at the end. The number is an ordering index.
  • The base64 decodes to bytes starting UEsDBBQ…. Decode the first few characters and you get 50 4B 03 04PK\x03\x04, the ZIP local-file-header magic. And right there in the readable part of the base64 you can spot the filename image.sif.

So the 360 XObjects are a base64-encoded ZIP file, split into 360 ordered pieces, each smuggled inside a fake 1×1 image attached to the PDF catalog.

💡 Why 1×1 images on the catalog? It’s a clean stego trick: the data is structurally valid PDF (it parses, it opens, it prints the rules fine), the objects are never rendered, and casual tools like pdfimages/pdftotext show nothing interesting. You only find it by reading the object graph.

Step 4 — Reassembling the ZIP

Now it’s just plumbing. For each object 292–651, pull the stream, strip the @@N: prefix and @@end suffix and any whitespace/newlines, sort by the index N, concatenate, and base64-decode:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import subprocess, re, base64

chunks = {}
for obj in range(292, 652):
    out = subprocess.run(["mutool", "show", "rules2.pdf", str(obj)],
                         capture_output=True, text=True).stdout
    if "stream" not in out:
        continue
    s = out.split("stream", 1)[1].rsplit("endstream", 1)[0].strip()
    m = re.match(r"@@(\d+):(.*?)@@end", s, re.S) or re.match(r"@@(\d+):(.*)", s, re.S)
    idx = int(m.group(1))
    chunks[idx] = re.sub(r"\s+", "", m.group(2))   # drop newlines inside the base64

print("chunks:", len(chunks), "range", min(chunks), "-", max(chunks))
b64 = "".join(chunks[i] for i in sorted(chunks))
raw = base64.b64decode(b64 + "=" * (-len(b64) % 4))
open("image_payload.zip", "wb").write(raw)
print("wrote", len(raw), "bytes, magic", raw[:4])
1
2
chunks: 360 range 0 - 359
wrote 3737177 bytes, magic b'PK\x03\x04'

A clean ZIP:

1
2
3
4
$ unzip -l image_payload.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
  3780608  2026-06-03 17:35   image.sif

Step 5 — The encrypted ZIP and the password hiding in plain sight

The archive is password-protected (the ZIP general-purpose bit flag 0x09 — encryption + deflate — is visible in the local header …\x14\x00\x09\x00…). Where’s the password?

Back to the actual content of the PDF. If you read the rules text all the way to the end with pdftotext, the document signs off with two out-of-place lines:

1
2
3
4
...
DONT SLOP
Thank you for your cooperation to make DalCTF fun for everyone!
teapot_2026

teapot_2026 is a dangling token with no business being in a rules document. (It’s also a cute nod to HTTP 418 “I’m a teapot” — appropriate for a challenge all about knowing the rules.) Try it as the ZIP password:

1
2
3
4
5
$ unzip -o -P teapot_2026 image_payload.zip
  inflating: image.sif

$ file image.sif
image.sif: a run-singularity script executable (binary data)

It works. We now have a Singularity / Apptainer container image (.sif).

Step 6 — Cracking open the SIF container

A SIF image is a header + descriptors wrapping a root filesystem. A quick strings grep tells us exactly what to look for, and reveals the container’s build definition embedded in its metadata:

1
2
3
4
$ strings -n 6 image.sif | grep -aiE "dalctf\{|flag"
./flag.txt /home/flag/flag.txt
...
"deffile":"Bootstrap: docker\nFrom: alpine:latest\n\n%files\n\t./flag.txt /home/flag/flag.txt"

So the image was built from alpine:latest and copies a local flag.txt to /home/flag/flag.txt. The actual filesystem is a squashfs embedded inside the SIF:

1
2
3
$ binwalk image.sif | grep -i squashfs
36864   0x9000   Squashfs filesystem, little endian, version 4.0,
                 compression:gzip, size: 3743391 bytes, 545 inodes, ...

You don’t need Singularity installed at all — just carve out the squashfs at offset 0x9000 and extract it:

1
2
3
4
$ dd if=image.sif bs=1 skip=36864 of=fs.squashfs
$ unsquashfs -d sqfs_out fs.squashfs home/flag/flag.txt
$ cat sqfs_out/home/flag/flag.txt
dalctf{n0w_y0u_r3ally_b3tt3r_kn0w_th3_rul3s}

Flag

1
dalctf{n0w_y0u_r3ally_b3tt3r_kn0w_th3_rul3s}

…and the flag itself delivers the punchline: now you really better know the rules. The whole challenge was hidden inside the rules document you were told to read.


Lessons / takeaways

  • File size is a signal. A 5 MB three-page text PDF is a contradiction. Always sanity-check size against content.
  • pdfimages and pdftotext are not the whole picture. They render the intended view. Real PDF forensics means reading the object graph (mutool show, qpdf --qdf, peepdf) and questioning anything attached to the catalog or to resources that never get drawn.
  • Watch for chunked/ordered markers. @@N:@@end across 360 objects screams “reassemble me.” Index numbers are there to tell you the order.
  • Magic bytes guide every step. PK\x03\x04 → ZIP; run-singularity / SIF magic → container; squashfs signature → carve and unsquashfs. Identify the format, then reach for the right tool.
  • Passwords love to hide in the obvious place. A stray token at the bottom of the very document you’re analyzing (teapot_2026) is exactly where a CTF author plants a key.
  • You rarely need the “official” tooling. No Singularity/Apptainer runtime required — dd + unsquashfs got us into the container directly.

Tools used

pdfinfo · pdftotext · pdfimages · pdfdetach · binwalk · mutool · python3 (base64 reassembly) · unzip · file · strings · dd · unsquashfs