Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
MiscMedium

Antakshari

CTF writeup for Antakshari from niteCTF

//Antakshari

Flag: nite{Diehard_1891771341083729}

>Challenge Summary

We receive two NumPy arrays:

  • handout/latent_vectors.npy – 201 embedding vectors, 64 dims each
  • handout/partial_edges.npy – four known edges connecting 6 actor nodes (cast members)

The hosted app asks: “Enter the Actor Node Sequence in Descending Order.” Submitting a correct 6-node sequence of actors returns the flag via /api/verify.

>Local Analysis

  1. Inspect shapes/values:
python
python3 - <<'PY'

import numpy as np

lv=np.load('handout/latent_vectors.npy')

edges=np.load('handout/partial_edges.npy')

print(lv.shape, edges.shape)

print(edges)

PY
  • 201 nodes, 64-dim vectors; 4 edges hint at two-movie pairs.
  1. Hypothesis: embeddings encode a bipartite movie–actor graph; similarities (dot products) are high between co-cast and lower otherwise.
  2. Split nodes into two clusters by embedding norm (high norm ≈ movies, low norm ≈ actors). KMeans on norms cleanly gives two groups (86 vs 115 nodes). Higher-norm cluster is treated as movies.
  3. Build similarity matrix M = lv @ lv.T and, for each movie, rank actor nodes by dot product. Top scores correlate with the partial edges (e.g., movie 149 ↔ actors 18,158,...; movie 12 ↔ 124,...; movie 45 ↔ 99,...).

>Automated Search for the Correct Cast

The web endpoint expects exactly six actor IDs in descending order. I automated trying every movie node: take its top-6 most similar actors, sort them descending, submit, and stop when the response is not “Incorrect”.

Script used (works locally and remotely—change url if needed):

python

import numpy as np, requests, json

from sklearn.cluster import KMeans

lv = np.load('handout/latent_vectors.npy')

norms = np.linalg.norm(lv, axis=1)

labels = KMeans(n_clusters=2, random_state=0).fit(norms.reshape(-1, 1)).labels_

movie_label = 1 if norms[labels==1].mean() > norms[labels==0].mean() else 0

movies = np.where(labels == movie_label)[0]

actors = np.where(labels != movie_label)[0]

M = lv @ lv.T

url = '<https://antakshari1.chall.nitectf25.live/api/verify>'

headers = {'Content-Type': 'application/json'}

for movie in movies:

    sims = M[movie, actors]

    order = np.argsort(-sims)

    top6 = actors[order[:6]]

    seq = ','.join(str(x) for x in sorted(top6, reverse=True))

    r = requests.post(url, headers=headers, data=json.dumps({'node_sequence': seq}), timeout=10)

    res = r.json().get('result', '')

    print(f"movie {movie} -> {seq} -> {res}")

    if 'Incorrect' not in res:

        break

When run, the first success appears at movie node 3:

movie 3 -> 189,177,134,108,37,29 -> nite{Diehard_1891771341083729}

So the required actor node sequence is 189,177,134,108,37,29 (already in descending order), yielding the flag.

>Remote Exploitation

No auth or CSRF—just POST JSON to /api/verify:

bash

curl -s -X POST <https://antakshari1.chall.nitectf25.live/api/verify> \\

  -H 'Content-Type: application/json' \\

  -d '{"node_sequence":"189,177,134,108,37,29"}'

Returns the flag string above.


>Full Solver (reproducible)

Below is the full solver.py script used during the contest; it is available at the project root. It implements the two-cluster norm split, selects top‑6 actor candidates per movie by dot product, sorts them in descending order (as required), and optionally queries the remote /api/verify endpoint.

python

#!/usr/bin/env python3

"""Solver for Antakshari challenge

Usage:

  python3 solver.py --local-only        # print candidate top-6 actor sequences

  python3 solver.py --url https://...   # test sequences against remote /api/verify

"""

import argparse

import json

import numpy as np

from sklearn.cluster import KMeans

def predict_top6(lv):

    norms = np.linalg.norm(lv, axis=1)

    labels = KMeans(n_clusters=2, random_state=0).fit(norms.reshape(-1, 1)).labels_

    # choose movie label as the cluster with higher mean norm

    movie_label = 1 if norms[labels == 1].mean() > norms[labels == 0].mean() else 0

    movies = np.where(labels == movie_label)[0]

    actors = np.where(labels != movie_label)[0]

    M = lv @ lv.T

    candidates = []

    for movie in movies:

        sims = M[movie, actors]

        order = np.argsort(-sims)

        top6 = actors[order[:6]]

        seq_sorted_desc = ','.join(str(x) for x in sorted(top6, reverse=True))

        candidates.append((movie, seq_sorted_desc, top6, sims[order[:6]]))

    return candidates

def query_remote(url, seq):

    import requests

    headers = {'Content-Type': 'application/json'}

    data = {'node_sequence': seq}

    r = requests.post(url, headers=headers, data=json.dumps(data), timeout=10)

    return r.status_code, r.text

def main():

    p = argparse.ArgumentParser()

    p.add_argument('--url', help='API verify URL (optional)')

    p.add_argument('--local-only', action='store_true', help='Do not query remote; just print candidates')

    p.add_argument('--vectors', default='handout/latent_vectors.npy', help='Path to latent_vectors.npy')

    args = p.parse_args()

    lv = np.load(args.vectors)

    cands = predict_top6(lv)

    # sort candidates by movie id for deterministic output

    cands = sorted(cands, key=lambda x: x[0])

    for movie, seq, top6, sims in cands:

        print(f'Movie {movie}: seq={seq}')

        if args.url and not args.local_only:

            code, text = query_remote(args.url, seq)

            print('  remote:', code, text)

            if 'nite{' in text:

                print('\\nFLAG FOUND:', text)

                return

    # Also attempt a focused heuristic: try top candidate per movie by similarity sum

    # (already chosen above) - print top result explicitly

    if not args.url or args.local_only:

        print('\\nNo remote URL provided or local-only mode. Candidates are shown above.')

if __name__ == '__main__':

    main()