Locks of Confusion 1: Weak-Key Match → Password-Protected Backup
On this page
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:
- Read the lore → find hidden archive paths.
- Pass a click-the-node check at
/verifyto unlock/archive/downloads. - Match the public key against the 300 private keys.
- Derive the ZIP password from the matched
.pemand extract the flag.
Recon
The portal has pages for inventory, backups, tickets, docs, notes, etc. Key breadcrumbs:
/wiki/notes(R. Alvarez, 2011): thesvc-legacydbkeypair 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.”/inventoryfooter 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 issvc-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_keysuniquely identifies the one real private key by matching modulus/exponent. - The
/verifygate 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.