← Back to Writeups
CUSTOMMediumForensics

Authenticated Handshake

XESXOR9/2/20264 min read
#forensics#custom#medium#hkdf_expand_label#hmac_verify_data#server_finished_cross_validati#tls13_keylog_decryption#transcript_hash#linux

Authenticated Handshake

Platform: cryptohack | Category: Forensics | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-21 | Status: Solved Techniques: hkdf_expand_label, hmac_verify_data, server_finished_cross_validation, tls13_keylog_decryption, transcript_hash

Summary

Task: TLS 1.3 PCAP + SSLKEYLOG; reconstruct the client Finished verify_data. Solution: decrypt the capture with tshark+keylog, extract the 6 tls.handshake_raw bodies (no record headers) for the transcript, derive finished_key via HKDF-Expand-Label from CLIENT_HANDSHAKE_TRAFFIC_SECRET, then HMAC-SHA384 over the transcript hash; cross-validated against the captured server Finished.

Authenticated Handshake — CryptoHack

Description

This is the CryptoHack "Authenticated Handshake" challenge (TLS Part 1 series). TLS 1.3 authentication happens in three handshake messages: Certificate, CertificateVerify, and Finished. The client Finished verify_data is:

verify_data = HMAC(finished_key, Transcript-Hash(Handshake Context, Certificate*, CertificateVerify*))
  • The HMAC uses the hash from the agreed cipher suite (here SHA-384).
  • finished_key is derived from the CLIENT_HANDSHAKE_TRAFFIC_SECRET (from keylogfile.txt) via HKDF-Expand-Label with label "finished".
  • Transcript-Hash = hash of all handshake messages concatenated WITHOUT record-layer headers.

The provided pcap captures the handshake up to (but not including) the client's encrypted Finished. The goal: fill in the blanks in client_finished.py and submit the computed verify_data hex. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: Given a TLS 1.3 capture, an SSLKEYLOG file, and a skeleton client_finished.py with ? blanks, recompute the client Finished verify_data and submit it as the answer. There is no FLAG{...} format — the hex string is the flag.

Analysis

TLS 1.3 vs TLS 1.2 Finished

In TLS 1.3 the Finished message authenticates the entire handshake transcript including Certificate and CertificateVerify (unlike TLS 1.2 where these come later). The client Finished verify_data is:

finished_key = HKDF-Expand-Label(client_handshake_traffic_secret, "finished", "", Hash.length)
verify_data  = HMAC(finished_key, Transcript-Hash(ClientHello … server Finished))

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The agreed cipher suite uses SHA-384, so Hash.length = 48.

Everything after ServerHello is encrypted

A naive tshark -Y "tls.handshake" shows only ClientHello (frame 8) and ServerHello (frame 10) — the EncryptedExtensions, Certificate, CertificateVerify and server Finished are encrypted under the handshake traffic keys. They become readable only after feeding tshark the SSLKEYLOG file.

The transcript needs handshake-layer bytes, not record-layer bytes

Transcript-Hash is computed over handshake messages = type(1) ‖ length(3) ‖ body, without the 5-byte TLS record header. Wireshark exposes exactly these bytes in the JSON field tls.handshake_raw, which made extraction clean. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

1. Decrypt the capture with the keylog file

bash
tshark -r no-finished-tls3_*.pcapng \
  -o "tls.keylog_file:keylogfile_*.txt" \
  -Y "tls.handshake" -T fields -e frame.number -e tls.handshake.type

After decryption, frame 10 reveals handshake types 2,8,11,15,20 (ServerHello, EncryptedExtensions, Certificate, CertificateVerify, Finished).

2. Dump decrypted JSON with raw bytes and extract the 6 messages

bash
tshark -r no-finished-tls3_*.pcapng \
  -o "tls.keylog_file:keylogfile_*.txt" \
  -Y "tls.handshake" -T json -x > frames.json

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

There are exactly 6 tls.handshake_raw blocks, in order:

#MessageTypeSize
1ClientHello1512 B
2ServerHello2122 B
3EncryptedExtensions819 B
4Certificate114037 B
5CertificateVerify15264 B
6(server) Finished2052 B

extract.py pulls them with a regex "tls\.handshake_raw":\s*\[\s*"([0-9a-fA-F]+)" and writes messages.json keyed by the variable names used in the skeleton.

3. Take the client handshake traffic secret from the keylog

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Line 4 of keylogfile.txt:

CLIENT_HANDSHAKE_TRAFFIC_SECRET ... ca8fb94500b13314d4c47158b1e9c7e5d3374cf9c5703b6d8ab879e99af1529d0e013b84ae1e7b15233ff64a1ed6e06c

4. Derive finished_key and compute verify_data

finished_key = HKDF-Expand-Label(secret, "finished", "", 48, SHA-384), then HMAC-SHA384 over SHA384(transcript).

python
import hmac, hashlib, struct, json
from math import ceil

HASH_ALG = hashlib.sha384
HASH_LEN = HASH_ALG().digest_size  # 48

def tls_HMAC(k, b, alg):
    return bytearray(hmac.new(k, b, alg).digest())
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def HKDF_expand(prk, info, length, alg):
    hash_len = alg().digest_size
    t = bytearray(); okm = bytearray()
    for i in range(1, ceil(length / hash_len) + 2):
        t = tls_HMAC(prk, t + info + bytearray([i]), alg)
        okm += t
    return okm[:length]

def HKDF_expand_label(secret, label, hashValue, length, alg):
    hkdfLabel  = struct.pack('>H', length)
    seq = bytearray(b"tls13 ") + label
    hkdfLabel += bytes([len(seq)]) + seq      # opaque label<7..255>
    hkdfLabel += bytes([len(hashValue)]) + hashValue  # opaque context<0..255>
    return HKDF_expand(secret, hkdfLabel, length, alg)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def verify_data(finished_key, transcript, alg):
    return tls_HMAC(finished_key, alg(transcript).digest(), alg)

msgs = json.load(open("messages.json"))
client_hello                     = bytes.fromhex(msgs["client_hello"])
server_hello                     = bytes.fromhex(msgs["server_hello"])
server_encrypted_extensions      = bytes.fromhex(msgs["server_encrypted_extensions"])
server_certificate_message       = bytes.fromhex(msgs["server_certificate_message"])
server_certificateverify_message = bytes.fromhex(msgs["server_certificateverify_message"])
server_finished                  = bytes.fromhex(msgs["server_finished"])
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

client_handshake_traffic_secret = bytes.fromhex(
    "ca8fb94500b13314d4c47158b1e9c7e5d3374cf9c5703b6d8ab879e99af1529d0e013b84ae1e7b15233ff64a1ed6e06c")

finished_key = HKDF_expand_label(
    client_handshake_traffic_secret, b"finished", b"", HASH_LEN, HASH_ALG)

transcript = (client_hello + server_hello + server_encrypted_extensions +
              server_certificate_message + server_certificateverify_message +
              server_finished)

print(verify_data(finished_key, transcript, HASH_ALG).hex())

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

5. Cross-validate against the captured server Finished (key insight)

Before trusting the client answer, recompute the server Finished using SERVER_HANDSHAKE_TRAFFIC_SECRET over the transcript ClientHello … CertificateVerify. The result must equal the server Finished bytes captured in the pcap (1400 0030 76f8f283…2a751689). It matched exactly, proving that the HKDF-Expand-Label structure, the transcript concatenation, and the HMAC are all correct — so the client computation (which we cannot observe in the pcap, since the client Finished is not captured) is trustworthy. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍