Locks of Confusion 1: Weak-Key Match → Password-Protected Backup

Flag: KPMG_CTF{g6zFPtocizPYUkS9yvjXgzuyNV-m2Q_7j6P2F0sYK0YY}

Target: http://kicyber-6a335957b5cd-locks-of-confusion1-0-0.chals.io Category: Crypto / web-recon: predictable RSA keypair (Debian-weak-key themed) → public-key match → ZIP password Hint (from KPMG CTF): “Internal Ops just migrated an old database replica onto new hardware and swears everything was done ‘by the book’. Somewhere sits a key that was decided long before it was ever generated.”

Overview

An “Internal Ops Portal” full of lore pages. The story: the svc-legacydb SSH keypair on legacy-03 (Debian 4.0 “etch”, provisioned 2007-11-14) was carried forward through a 2011 P2V migration without regeneration: a key “decided long before it was generated” (the predictable Debian OpenSSL keyspace, CVE-2008-0166 flavour). The challenge hands you a keyspace archive of 300 candidate private keys and the deployed authorized_keys public key; matching them yields the real private key, whose base64 body is the password to a protected backup ZIP containing the flag.

The path:

  1. Read the lore → find hidden archive paths.
  2. Pass a click-the-node check at /verify to unlock /archive/ downloads.
  3. Match the public key against the 300 private keys.
  4. Derive the ZIP password from the matched .pem and extract the flag.

Recon

The portal has pages for inventory, backups, tickets, docs, notes, etc. Key breadcrumbs:

  • /wiki/notes (R. Alvarez, 2011): the svc-legacydb keypair on legacy-03 “is the original one from when I first provisioned that box back in Nov 2007 … we just carried it forward through the migration instead of rotating it.”
  • /docs/migration-2011-004: legacy-03 = Debian 4.0 etch, provisioned 2007-11-14; SSH keys “carried over without regeneration”; a partial tape restore of /etc/ssh, /home/svc-legacydb, /var/log/provision.log “left in cold storage … not linked from the main archive index.”
  • /inventory footer hint: “It’s not the j0urn3y nor the d3stin4ti0n, it’s the l3g4cy”, and an HTML comment: “You’ll find the answer in the right place and that’s your ticket to the next phase.”

robots.txt + ticket comments leak the paths

curl -s "$base/robots.txt"
# Disallow: /internal-only/   /old-admin/   /.git/   /archive/keyspace/README.txt
# "you're near but still far ... don't leave the inventory ... the SoUrCe"

/archive/keyspace/README.txt (base64) decodes to a note about “300 old keys … you probably already know what to do with them.” The exact archive paths are in /tickets/TICK-4471 HTML comments:

<!-- TODO(infra): ... /archive/provisioning/legacy-03-2007.tar.gz -->
<!-- TODO(infra): ... /archive/keyspace/legacy-keys-2007.tar.gz -->

Unlocking /archive via the /verify click-check

Fetching the tarballs returns 403 Forbidden -- complete the access check at /verify first.

/verify renders a network diagram image and JS that, on click, POSTs {nonce, x, y} to /verify/check; you must click the node whose label matches the instruction (e.g. “click the node labeled cache-02”). Both the nonce/image and the target label are randomized per page load, and the node positions are random, so it must be solved programmatically per load:

# 1) load /verify: extract nonce, image id, and target label (same response)
# 2) download /verify/image/<id>, detect the blue circles' centers (PIL)
# 3) identify which circle carries the target label, POST its center
import urllib.request, ssl, re, http.cookiejar
from PIL import Image
# ... load /verify, parse nonce / imgid / target ("cache-02") ...
# ... detect blue blobs -> circle centers ...
# ... (view the annotated image to map label->circle) ...

Detected 6 circle centers; viewing the annotated image mapped cache-02 → center (525, 293):

curl -s -b vf.txt -X POST "$base/verify/check" -H 'Content-Type: application/json' \
  --data '{"nonce":"9b0dd5898b64e1a5171b2bc9837845e0","x":525,"y":293}'
# {"ok":true}

With the unlocked session cookie, both archives download (HTTP 200):

curl -s -b vf.txt -o provisioning.tar.gz "$base/archive/provisioning/legacy-03-2007.tar.gz"   # 1.5 KB
curl -s -b vf.txt -o keyspace.tar.gz     "$base/archive/keyspace/legacy-keys-2007.tar.gz"      # 360 KB

The archives

provisioning/legacy-03-2007/home/svc-legacydb/.ssh/authorized_keys   <- deployed PUBLIC key
provisioning/legacy-03-2007/var/log/provision.log                    <- confirms the keygen
keyspace/privkey_0000.pem ... privkey_0299.pem                       <- 300 candidate PRIVATE keys
keyspace/README.txt                                                  <- how to use them

provision.log nails the weak-key context:

Nov 14 2007 ... apt: installing openssl (0.9.8c-4)
Nov 14 2007 ... provision: ssh-keygen -t rsa -b 2048 -f /home/svc-legacydb/.ssh/id_rsa -N ''
Nov 14 2007 ... provision: public key installed to authorized_keys for svc-legacydb

keyspace/README.txt gives the win condition:

match one of the 300 keys against the deployed authorized_keys (that account is svc-legacydb). There’s a leftover password-protected backup at /archive/backups/e6f25638ae2ed6ba955cdae6a74c6798.zip. The password is the base64 BODY of the matching .pem: strip the -----BEGIN/END----- lines and all newlines, and take the first 64 characters.

Matching the key (SOLVED)

Load the SSH public key and compare (n, e) against every candidate private key:

from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_ssh_public_key
import glob
pub = load_ssh_public_key(open("authorized_keys","rb").read().strip()).public_numbers()
for f in sorted(glob.glob("keyspace/privkey_*.pem")):
    k = load_pem_private_key(open(f,"rb").read(), password=None).private_numbers().public_numbers
    if k.n == pub.n and k.e == pub.e:
        print("MATCH", f); break

Output: MATCH keyspace/privkey_0137.pem (2048-bit, e=65537).

Derive the ZIP password = first 64 chars of the PEM’s base64 body:

MIIEowIBAAKCAQEAui2yyUzRtrahcJ1ZpfIrOBvT+s9ZI4k0mXQMegwGWfeKQ4cA

Download and extract the backup with it:

curl -s -b vf.txt -o backup.zip "$base/archive/backups/e6f25638ae2ed6ba955cdae6a74c6798.zip"
unzip -P 'MIIEowIBAAKCAQEAui2yyUzRtrahcJ1ZpfIrOBvT+s9ZI4k0mXQMegwGWfeKQ4cA' backup.zip
cat user.txt
# KPMG_CTF{g6zFPtocizPYUkS9yvjXgzuyNV-m2Q_7j6P2F0sYK0YY}

Flag: KPMG_CTF{g6zFPtocizPYUkS9yvjXgzuyNV-m2Q_7j6P2F0sYK0YY}

Why it works

  • A keypair carried forward from 2007 is in a tiny, pre-decided keyspace (“decided before it was generated”). The challenge models this by shipping the 300 candidate keys; the public key in authorized_keys uniquely identifies the one real private key by matching modulus/exponent.
  • The /verify gate is just an authorization speed-bump: a randomized image click, fully scriptable (parse nonce+target, detect node centers, POST coordinates).
  • The ZIP password is deterministically derived from the secret key material, so once you’ve matched the key you already hold the password (as the README spells out).

Root cause & fix

  • Never reuse keys generated by a vulnerable/old toolchain, and rotate keys on migration rather than preserving fingerprints for convenience. A key whose keyspace is predictable is already compromised.
  • Don’t leave secret material (candidate keyspaces, private keys, provisioning bundles) on a web-reachable host, and don’t gate it behind a trivially-scriptable check.
  • Don’t derive a backup password from the key it protects; that couples two secrets so cracking one yields the other.
  • Remove .git, archive paths, and sensitive TODOs from HTML/robots before shipping.

Takeaways

  • Read ALL the lore: robots.txt, page footers, and especially HTML comments on ticket pages held the exact /archive/... paths.
  • When a key’s provenance is “old Debian + 2006–2008”, think predictable keyspace: match the deployed public key against the candidate set rather than cracking anything.
  • A per-request randomized “click the right node” check is automatable: pull the nonce + target from the same load, locate the node (blob detection / vision), POST the coordinates.