Golden Hour: OSINT
On this page
Flag: KPMG_CTF{n0va_dr1ft3d_h0m3}
Brief
I never delete, I just stop pointing. Everything I walked away from is still out there, and the one thing worth keeping, I locked. The key’s already yours
Target: https://kicyber-850640751f79-goldenhour-0-0.chals.io/
“Stop pointing” refers to git refs: a force-push moves a branch to new commits, but the old commits are still on GitHub. “Walked away from” refers to an old LinkedIn handle and earlier commit history. “Locked” is the client-side encrypted vault.
1. The website
The site belongs to Nova Keller, an indie game developer and film photographer. It has three useful leads:
- A link to the vault (
drop.html). - Links to GitHub user
nova-drifts. - A journal entry: “Changed the name a while back… Everything I build still goes up in the open - warts, rebases and all.” This hints at a renamed handle and rewritten git history.
drop.html decrypts in the browser in two steps. It uses PBKDF2-SHA256 (200,000 iterations)
and AES-256-GCM, and the data blobs D.b1 and D.b2 are embedded in the page:
- Step 1:
passphrase→ decrypts a photo. - Step 2:
passphrase|place→ decrypts the “keeper” photo.
Inputs are normalized with toLowerCase().replace(/[^a-z0-9]/g,'').
2. GitHub: decoys first
nova-drifts has five repos, all committed as nvk_dev. Three of them are traps:
| Repo | Content |
|---|---|
config-archive |
old.env has FLAG=KPMG_CTF{n0va_dr1fted_h0m3}, and its comment calls it a DEAD END. Decoy. |
dotfiles |
NOTES.md: “Wrong repo. The trail starts in the repo that *is my website”* |
ld53-jam, shader-doodles |
Filler |
driftlog |
The website source. This is where the trail continues. |
Note that the decoy is spelled dr1fted, while the real flag is dr1ft3d.
3. driftlog: commits that “stopped being pointed at”
The current main branch only shows a deleted humans.txt (commit 437db6b “tidy repo, drop
scratch notes”):
Nova Keller
# socials draft - decide which handle I am actually keeping
linkedin: https://www.linkedin.com/in/nova-drifts/
linkedin: https://www.linkedin.com/in/nova-keller-/
The repo was force-pushed several times. The public events API still records each push’s
head and before SHAs:
$ curl -s "https://api.github.com/users/nova-drifts/events/public?per_page=100" | grep -E '"(head|before)"'
"head": "9e114af13b16d367559fa6b875ef7d2d2619e5a2", "before": "93f9b518bce3a3393c4a1b3506cb18e2442de475"
"head": "1bb52ebba53b187a0ee87e6e85917d422fd42c53", "before": "9e114af1..."
"head": "7c655e90a30dbe0e7e41bd154dcbb537994fcef0", "before": "1bb52ebb..."
GitHub still serves these unreferenced commits by SHA:
$ git clone https://github.com/nova-drifts/driftlog && cd driftlog
$ git fetch origin 93f9b518bce3a3393c4a1b3506cb18e2442de475
$ git log -p --reverse 93f9b51
The oldest rewritten chain (93f9b51) has the real README:
I rebased this repo more than once, so the commits didn’t land in the order I actually wrote them. If you want the real sequence, go by the author timestamps … First letter of each, in that order.
Field notes (encrypted) … https://www.linkedin.com/in/nova-drifts/ - read the About section. It’s a classic cipher. The key is a word I’ve spelled out across this project’s history.
To get the key, sort that chain’s commits by author date and take the first letter of each message:
| Author date | Message | Letter |
|---|---|---|
| 2024-02-01 | Nothing fancy yet - initial static site skeleton | N |
| 2024-02-04 | Initial gallery grid and hero section | I |
| 2024-02-09 | Give the bio section room to breathe | G |
| 2024-02-15 | Hook up the (placeholder) contact section | H |
| 2024-02-20 | Tweak the type scale - happy with Fraunces now | T |
| 2024-02-24 | Jitter fix on the tile hover animation | J |
| 2024-03-01 | Add a 404 page and basic meta tags | A |
| 2024-03-06 | Responsive pass for small screens | R |
Cipher key: NIGHTJAR
4. LinkedIn: the handle “walked away from”
The README points to /in/nova-drifts/, but that profile now returns 404 because the handle was
changed. The second handle from humans.txt, /in/nova-keller-/, is the live profile.
LinkedIn returns HTTP 999 to curl and to Googlebot user-agents. It serves the public profile to
link-preview crawlers:
$ curl -sL -A "facebookexternalhit/1.1" https://www.linkedin.com/in/nova-keller-/
The logged-out view cuts the About section off after its opening line, y0u n33d 70 r34d 7h15
(“you need to read this”). The page also embeds the profile’s post (“Three years chasing the
same light…“), and the owner left notes in its comments:
| Comment | Decoded |
|---|---|
bjA3IDdoNDcgMzQ1eXl5 (base64) |
n07 7h47 345yyy (“not that easyyy”) |
eTB1IDRyMyA0bG0wNTcgN2gzcjM= (base64) |
y0u 4r3 4lm057 7h3r3 (“you are almost there”) |
MRZGSZTUO5XW6ZA= (base32) |
driftwood |
O5ZG63THNZXXIZI= (base32) |
wrongnote (decoy) |
The intended route is probably to decrypt the full About text with the Vigenère key NIGHTJAR.
I couldn’t read the full About section without logging in, so I tested the decoded words
directly. driftwood opens step 1.
5. Vault step 1: the passphrase
To test candidates offline instead of through the form, I reimplemented the page’s decryption:
import re, json, base64, hashlib, sys
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
D = json.loads(re.search(r'var D=(\{.*?\});\s*\n', open('drop.html').read(), re.S).group(1))
norm = lambda x: re.sub(r'[^a-z0-9]', '', x.lower())
def dec(p, b):
k = hashlib.pbkdf2_hmac('sha256', p.encode(), base64.b64decode(b['s']), b['it'], 32)
return json.loads(AESGCM(k).decrypt(base64.b64decode(b['iv']), base64.b64decode(b['d']), None))
print(dec(norm('driftwood'), D['b1'])['msg'])
# Not done yet - one more step to go! Guess the place in this shot to unlock the flag.
wrongnote and nightjar both fail. The decrypted photo shows a white lighthouse with a red
lantern on smooth, rounded granite, photographed at sunset over the sea.
6. Vault step 2: the place
This is Peggy’s Point Lighthouse at Peggy’s Cove, Nova Scotia. (Nova Keller, Nova Scotia, and “n0va_dr1ft3d_h0m3” are all related.)
print(dec(norm('driftwood') + '|' + norm("Peggy's Cove"), D['b2'])['msg'])
# Unlocked. Here's the keeper, full resolution - it's yours.
# (a good photo carries more than the pixels.)
7. The keeper: EXIF
“More than the pixels” refers to metadata. The flag is a plain string in the EXIF sub-IFD:
$ strings -n 6 -t d golden-hour.jpg | grep KPMG
176 KPMG_CTF{n0va_dr1ft3d_h0m3}
Summary
| Step | Source | Gives |
|---|---|---|
| Decoy | config-archive/old.env |
KPMG_CTF{n0va_dr1fted_h0m3} (fake) |
| Handles | deleted humans.txt in driftlog |
nova-drifts, nova-keller- |
| Rewritten history | events API → git fetch <orphaned sha> |
README with puzzle instructions |
| Cipher key | first letters of commits, by author date | NIGHTJAR |
| Passphrase | LinkedIn nova-keller- post comments (base32) |
driftwood |
| Place | step-1 photo (Peggy’s Point Lighthouse) | peggyscove |
| Flag | EXIF of the keeper photo | KPMG_CTF{n0va_dr1ft3d_h0m3} |
Takeaways: force-pushing doesn’t delete commits. The GitHub events API leaks the old SHAs, and GitHub serves those commits by hash. Order rebased history by author date, not commit order. A deleted handle usually has a renamed successor nearby. When LinkedIn blocks scraping, the link-preview user-agent often gets through. Check what the page embeds, such as post comments, not only the visible profile. Compare similar-looking flags character by character.