│ Category: Cryptography
│ Difficulty: Easy
│ Points: 100
│ Author: Imattas aka Zemi
────────────────────────────────────────────────────────────────────────────────
--[ Challenge Description ]--
│ A message was encrypted using a shared secret... but it looks like one side of
the exchange leaked something. Can you piece it together?
────────────────────────────────────────────────────────────────────────────────
--[ Recon / Initial Analysis ]--
This is a Diffie-Hellman key exchange challenge where one party's private key
has been leaked. With access to the public parameters and the leaked private
key, we can reconstruct the shared secret and decrypt the message.
:: Diffie-Hellman Key Exchange Background
The Diffie-Hellman (DH) protocol allows two parties (Alice and Bob) to establish
a shared secret over an insecure channel:
1. Public parameters: A large prime p and a generator g are agreed upon
publicly.
2. Private keys: Alice chooses a secret a, Bob chooses a secret b.
3. Public keys:
- Alice computes A = g^a mod p and sends A to Bob
- Bob computes B = g^b mod p and sends B to Alice
4. Shared secret:
- Alice computes s = B^a mod p
- Bob computes s = A^b mod p
- Both arrive at the same value: s = g^(a*b) mod p
:: What We Are Given (Typical)
The challenge likely provides a file or data containing:
- p -- the prime modulus
- g -- the generator
- A -- Alice's public key
- B -- Bob's public key
- One of a or b -- the leaked private key
- ciphertext -- the encrypted message (possibly with an iv for AES-CBC)
────────────────────────────────────────────────────────────────────────────────
--[ Vulnerability / Observation ]--
If either private key is leaked, the shared secret can be computed by anyone who
also knows the corresponding public key:
- If Alice's private key a is leaked: s = B^a mod p
- If Bob's private key b is leaked: s = A^b mod p
:: Encryption Scheme
Once the shared secret is derived, it is typically used to encrypt a message.
Common methods in CTF challenges:
- AES encryption: The shared secret (or its hash) is used as an AES key (often
SHA-256 of the shared secret, truncated to 16 or 32 bytes)
- XOR encryption: The message is XORed with the shared secret (or bytes derived
from it)
- Simple modular conversion: The shared secret directly contains or encodes the
flag
────────────────────────────────────────────────────────────────────────────────
--[ Exploitation / Solution ]--
1. Extract the parameters from the provided file(s):
- Prime p, generator g
- Public keys A and B
- Leaked private key (a or b)
- Ciphertext and optionally IV/nonce
2. Compute the shared secret:
-- python --
# If 'a' (Alice's private key) is leaked:
shared_secret = pow(B, a, p)
# If 'b' (Bob's private key) is leaked:
shared_secret = pow(A, b, p)
3. Derive the decryption key: The shared secret is usually processed before use:
-- python --
import hashlib
key = hashlib.sha256(str(shared_secret).encode()).digest()[:16] # AES-128
# or
key = hashlib.sha256(long_to_bytes(shared_secret)).digest() # AES-256
# or
key = shared_secret # direct use for XOR
4. Decrypt the message:
- For AES-CBC: AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
- For AES-ECB: AES.new(key, AES.MODE_ECB).decrypt(ciphertext)
- For XOR: bytes(c ^ k for c, k in zip(ciphertext, cycle(key_bytes)))
5. Extract the flag from the decrypted plaintext.
────────────────────────────────────────────────────────────────────────────────
--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
Shared Secrets - picoCTF 2026 (Cryptography, 100 pts)
Exploit a leaked private key in a Diffie-Hellman key exchange to compute
the shared secret and decrypt the flag.
Usage:
python3 solve.py [challenge_file]
If no file is provided, the script will look for common filenames in the
current directory and also demonstrate the approach with example values.
Dependencies: pycryptodome (pip install pycryptodome)
"""
import sys
import os
import re
import json
import hashlib
from itertools import cycle
# Try to import pycryptodome for AES decryption
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes
HAS_CRYPTO = True
except ImportError:
HAS_CRYPTO = False
# Fallback long_to_bytes implementation
def long_to_bytes(n, blocksize=0):
s = b''
n_int = int(n)
while n_int > 0:
s = bytes([n_int & 0xFF]) + s
n_int >>= 8
if blocksize > 0 and len(s) % blocksize:
s = (blocksize - len(s) % blocksize) * b'\x00' + s
return s or b'\x00'
def parse_challenge_file(filepath):
"""
Parse the challenge file to extract DH parameters and ciphertext.
Supports multiple common formats: JSON, key=value, Python-like assignments.
"""
print(f"[*] Parsing: {filepath}")
with open(filepath, 'r') as f:
content = f.read()
params = {}
# Try JSON format first
try:
data = json.loads(content)
params = {k.lower(): v for k, v in data.items()}
print(" [*] Parsed as JSON")
return params
except (json.JSONDecodeError, AttributeError):
pass
# Try key=value or key: value format
# Matches: p = 12345, p: 12345, p=12345, etc.
patterns = [
# Variable assignment: p = 12345 or p = 0x...
(r'(\w+)\s*=\s*(0x[0-9a-fA-F]+|\d+)', 'assignment'),
# Colon-separated: p: 12345
(r'(\w+)\s*:\s*(0x[0-9a-fA-F]+|\d+)', 'colon'),
# Quoted hex string values: ciphertext = "aabbcc..."
(r'(\w+)\s*[=:]\s*["\']([0-9a-fA-F]+)["\']', 'hex_string'),
# Base64 values: ciphertext = "base64..."
(r'(\w+)\s*[=:]\s*["\']([A-Za-z0-9+/=]+)["\']', 'b64_string'),
]
for pattern, fmt in patterns:
for match in re.finditer(pattern, content):
key = match.group(1).lower()
val = match.group(2)
if fmt in ('assignment', 'colon'):
if val.startswith('0x'):
params[key] = int(val, 16)
else:
params[key] = int(val)
else:
params[key] = val
if params:
print(f" [*] Extracted {len(params)} parameters")
else:
print(" [!] Could not parse parameters from file")
return params
def compute_shared_secret(params):
"""Compute the Diffie-Hellman shared secret from the given parameters."""
# Identify the parameters (handle various naming conventions)
p = params.get('p') or params.get('prime') or params.get('modulus')
g = params.get('g') or params.get('generator') or params.get('base')
a_pub = params.get('a') or params.get('a_pub') or params.get('alice_public') or params.get('public_a')
b_pub = params.get('b') or params.get('b_pub') or params.get('bob_public') or params.get('public_b')
a_priv = params.get('a_priv') or params.get('alice_private') or params.get('private_a') or params.get('a_secret')
b_priv = params.get('b_priv') or params.get('bob_private') or params.get('private_b') or params.get('b_secret')
if p is None:
print("[!] Prime modulus 'p' not found in parameters")
return None
print(f"\n[*] DH Parameters:")
print(f" p = {str(p)[:60]}{'...' if len(str(p)) > 60 else ''}")
if g: print(f" g = {g}")
if a_pub: print(f" A (public) = {str(a_pub)[:60]}{'...' if len(str(a_pub)) > 60 else ''}")
if b_pub: print(f" B (public) = {str(b_pub)[:60]}{'...' if len(str(b_pub)) > 60 else ''}")
if a_priv: print(f" a (LEAKED private) = {str(a_priv)[:60]}{'...' if len(str(a_priv)) > 60 else ''}")
if b_priv: print(f" b (LEAKED private) = {str(b_priv)[:60]}{'...' if len(str(b_priv)) > 60 else ''}")
# Compute the shared secret
if a_priv and b_pub:
print("\n[*] Computing shared secret: s = B^a mod p")
shared_secret = pow(int(b_pub), int(a_priv), int(p))
elif b_priv and a_pub:
print("\n[*] Computing shared secret: s = A^b mod p")
shared_secret = pow(int(a_pub), int(b_priv), int(p))
elif a_priv and g:
# If only one public key is available, and it's not labeled clearly
# Check if 'a' is actually the other party's public key
print("\n[*] Trying: s = A^(leaked_key) mod p")
shared_secret = pow(int(a_pub), int(a_priv), int(p)) if a_pub else None
if shared_secret is None:
print("[!] Not enough information to compute shared secret")
return None
else:
print("[!] Need a leaked private key and the other party's public key")
return None
print(f"[+] Shared secret = {str(shared_secret)[:60]}{'...' if len(str(shared_secret)) > 60 else ''}")
return shared_secret
def derive_key(shared_secret, method='sha256_str'):
"""Derive an encryption key from the shared secret using common methods."""
methods = {}
# Method 1: SHA-256 of the string representation
methods['sha256_str_16'] = hashlib.sha256(str(shared_secret).encode()).digest()[:16]
methods['sha256_str_32'] = hashlib.sha256(str(shared_secret).encode()).digest()
# Method 2: SHA-256 of the byte representation
ss_bytes = long_to_bytes(shared_secret)
methods['sha256_bytes_16'] = hashlib.sha256(ss_bytes).digest()[:16]
methods['sha256_bytes_32'] = hashlib.sha256(ss_bytes).digest()
# Method 3: MD5 of string representation
methods['md5_str'] = hashlib.md5(str(shared_secret).encode()).digest()
# Method 4: MD5 of byte representation
methods['md5_bytes'] = hashlib.md5(ss_bytes).digest()
# Method 5: Direct bytes (truncated/padded to 16 or 32)
if len(ss_bytes) >= 16:
methods['direct_16'] = ss_bytes[:16]
if len(ss_bytes) >= 32:
methods['direct_32'] = ss_bytes[:32]
# Method 6: SHA-1 of string
methods['sha1_str'] = hashlib.sha1(str(shared_secret).encode()).digest()[:16]
return methods
def decrypt_message(ciphertext_raw, key, iv=None):
"""Try to decrypt the ciphertext using AES with the given key."""
results = []
if not HAS_CRYPTO:
# Fallback: XOR decryption only
decrypted = bytes(c ^ k for c, k in zip(ciphertext_raw, cycle(key)))
return [('XOR', decrypted)]
# Try AES-CBC
if iv:
try:
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted = cipher.decrypt(ciphertext_raw)
try:
decrypted = unpad(decrypted, AES.block_size)
except ValueError:
pass # Might not be PKCS7 padded
results.append(('AES-CBC', decrypted))
except Exception:
pass
# Try AES-CBC with first 16 bytes as IV
if len(ciphertext_raw) > 16 and not iv:
try:
iv_guess = ciphertext_raw[:16]
ct = ciphertext_raw[16:]
cipher = AES.new(key, AES.MODE_CBC, iv_guess)
decrypted = cipher.decrypt(ct)
try:
decrypted = unpad(decrypted, AES.block_size)
except ValueError:
pass
results.append(('AES-CBC (IV=first16)', decrypted))
except Exception:
pass
# Try AES-ECB
try:
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(ciphertext_raw)
try:
decrypted = unpad(decrypted, AES.block_size)
except ValueError:
pass
results.append(('AES-ECB', decrypted))
except Exception:
pass
# Try AES-CTR with various nonce configurations
if len(ciphertext_raw) > 8:
try:
from Crypto.Cipher import AES as AES2
nonce = ciphertext_raw[:8]
ct = ciphertext_raw[8:]
cipher = AES2.new(key, AES2.MODE_CTR, nonce=nonce)
decrypted = cipher.decrypt(ct)
results.append(('AES-CTR (nonce=first8)', decrypted))
except Exception:
pass
# Try XOR
decrypted = bytes(c ^ k for c, k in zip(ciphertext_raw, cycle(key)))
results.append(('XOR', decrypted))
return results
def solve(params):
"""Main solving logic."""
# Step 1: Compute shared secret
shared_secret = compute_shared_secret(params)
if shared_secret is None:
return None
# Step 2: Check if the shared secret itself contains the flag
ss_str = str(shared_secret)
ss_bytes = long_to_bytes(shared_secret)
ss_hex = ss_bytes.hex()
# Check if shared secret bytes decode to the flag
try:
decoded = ss_bytes.decode('utf-8', errors='ignore')
if 'picoCTF' in decoded:
match = re.search(r'picoCTF\{[^}]+\}', decoded)
if match:
return match.group(0)
except Exception:
pass
# Check hex representation
try:
flag_bytes = bytes.fromhex(ss_hex)
decoded = flag_bytes.decode('utf-8', errors='ignore')
if 'picoCTF' in decoded:
match = re.search(r'picoCTF\{[^}]+\}', decoded)
if match:
return match.group(0)
except Exception:
pass
# Step 3: Get ciphertext
ciphertext = params.get('ciphertext') or params.get('ct') or params.get('cipher') or params.get('encrypted') or params.get('message') or params.get('c')
iv = params.get('iv') or params.get('nonce')
if ciphertext is None:
print("\n[!] No ciphertext found in parameters")
print(f"[*] Shared secret (decimal): {shared_secret}")
print(f"[*] Shared secret (hex): {ss_hex}")
print(f"[*] Shared secret (bytes): {ss_bytes}")
return None
# Convert ciphertext to bytes
if isinstance(ciphertext, str):
try:
ciphertext_raw = bytes.fromhex(ciphertext)
except ValueError:
import base64
try:
ciphertext_raw = base64.b64decode(ciphertext)
except Exception:
ciphertext_raw = ciphertext.encode()
elif isinstance(ciphertext, int):
ciphertext_raw = long_to_bytes(ciphertext)
else:
ciphertext_raw = ciphertext
# Convert IV to bytes if present
iv_raw = None
if iv:
if isinstance(iv, str):
try:
iv_raw = bytes.fromhex(iv)
except ValueError:
iv_raw = iv.encode()
elif isinstance(iv, int):
iv_raw = long_to_bytes(iv)
# Pad IV to 16 bytes
if len(iv_raw) < 16:
iv_raw = b'\x00' * (16 - len(iv_raw)) + iv_raw
print(f"\n[*] Ciphertext ({len(ciphertext_raw)} bytes): {ciphertext_raw[:32].hex()}...")
if iv_raw:
print(f"[*] IV ({len(iv_raw)} bytes): {iv_raw.hex()}")
# Step 4: Derive keys and try decryption
print("\n[*] Trying all key derivation and decryption combinations...")
key_methods = derive_key(shared_secret)
for key_name, key in key_methods.items():
if len(key) not in (16, 24, 32):
continue
results = decrypt_message(ciphertext_raw, key, iv_raw)
for mode, plaintext in results:
# Check if decrypted text contains the flag
try:
decoded = plaintext.decode('utf-8', errors='ignore')
except Exception:
decoded = str(plaintext)
if 'picoCTF' in decoded:
match = re.search(r'picoCTF\{[^}]+\}', decoded)
if match:
print(f"\n[+] Decrypted with key={key_name}, mode={mode}")
print(f"[+] Plaintext: {decoded}")
return match.group(0)
# Also check raw bytes
if b'picoCTF' in plaintext:
match = re.search(rb'picoCTF\{[^}]+\}', plaintext)
if match:
print(f"\n[+] Decrypted with key={key_name}, mode={mode}")
return match.group(0).decode()
print("\n[!] No valid decryption found with standard methods")
print("[*] The challenge may use a non-standard key derivation or cipher")
return None
def main():
# Try to find the challenge file
challenge_file = None
if len(sys.argv) >= 2:
challenge_file = sys.argv[1]
else:
# Look for common challenge file names
common_names = [
'challenge.txt', 'params.txt', 'shared_secrets.txt',
'exchange.txt', 'data.txt', 'output.txt', 'out.txt',
'challenge.json', 'params.json', 'values.txt',
'intercept.txt', 'captured.txt', 'dh_params.txt',
]
for name in common_names:
if os.path.isfile(name):
challenge_file = name
print(f"[*] Found challenge file: {name}")
break
if challenge_file is None:
print("Shared Secrets - picoCTF 2026 (Cryptography, 100 pts)")
print()
print("Usage: python3 solve.py <challenge_file>")
print()
print("The challenge file should contain the DH parameters:")
print(" p, g, A (Alice's public), B (Bob's public),")
print(" leaked private key (a or b), and the ciphertext.")
print()
print("Supported formats: JSON, key=value, Python-like assignments")
print()
print("Example challenge file (params.txt):")
print(" p = 13407807929942597099574024998205846127479365820592393377723561443721764...")
print(" g = 2")
print(" A = 89234872348723487...")
print(" B = 72348723487234872...")
print(" a_priv = 12345678901234...")
print(" ciphertext = aabbccdd...")
print(" iv = 00112233...")
print()
# Run with example values to demonstrate
print("=" * 50)
print("[*] Running demonstration with example values...")
print("=" * 50)
# Small example for demonstration
demo_p = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA237327FFFFFFFFFFFFFFFF
demo_g = 2
demo_a_priv = 123456789 # Leaked private key (small for demo)
demo_A = pow(demo_g, demo_a_priv, demo_p)
demo_b_priv = 987654321
demo_B = pow(demo_g, demo_b_priv, demo_p)
demo_shared = pow(demo_B, demo_a_priv, demo_p)
# Encrypt a demo flag
demo_flag = b"picoCTF{d1ff13_h3llm4n_1s_n0t_s0_s3cr3t}"
demo_key = hashlib.sha256(str(demo_shared).encode()).digest()[:16]
if HAS_CRYPTO:
demo_iv = os.urandom(16)
cipher = AES.new(demo_key, AES.MODE_CBC, demo_iv)
from Crypto.Util.Padding import pad
demo_ct = cipher.encrypt(pad(demo_flag, AES.block_size))
demo_params = {
'p': demo_p,
'g': demo_g,
'a_pub': demo_A,
'b_pub': demo_B,
'a_priv': demo_a_priv,
'ciphertext': demo_ct.hex(),
'iv': demo_iv.hex(),
}
flag = solve(demo_params)
if flag:
print(f"\n{'='*50}")
print(f"DEMO FLAG: {flag}")
print(f"{'='*50}")
print("\n[*] This was a demonstration. Run with your challenge file:")
print(" python3 solve.py <challenge_file>")
else:
print("[!] pycryptodome not installed, cannot run demo")
print(" pip install pycryptodome")
sys.exit(0)
if not os.path.isfile(challenge_file):
print(f"[!] File not found: {challenge_file}")
sys.exit(1)
# Parse the challenge file
params = parse_challenge_file(challenge_file)
if not params:
print("[!] Failed to parse challenge file")
sys.exit(1)
print(f"[*] Parameters found: {list(params.keys())}")
# Solve
flag = solve(params)
# Final output
print()
if flag:
print(f"{'='*50}")
print(f"FLAG: {flag}")
print(f"{'='*50}")
else:
print("[!] Flag not found automatically.")
print()
print("[*] Manual steps:")
print(" 1. Verify the parameter names match what the script expects")
print(" 2. Check the key derivation method (SHA-256 of string? of bytes?)")
print(" 3. Check the cipher mode (AES-CBC? AES-ECB? AES-CTR? XOR?)")
print(" 4. Try: shared_secret = pow(B, a, p) in a Python shell")
print(" 5. The shared secret itself might encode the flag")
if __name__ == '__main__':
main()
────────────────────────────────────────────────────────────────────────────────
--[ Key Takeaways ]--
- Diffie-Hellman security relies entirely on private keys staying secret —
leaking either a or b collapses the whole exchange.
- Given a leaked private key and the other party's public key, the shared secret
is a single modular exponentiation: s = B^a mod p (or s = A^b mod p).
- The shared secret is rarely used directly; common derivations are SHA-256 of
its string or byte form, truncated to AES key sizes.
- When the cipher/key-derivation isn't specified, brute-force the combinations
(AES-CBC/ECB/CTR/XOR × multiple key derivations) and grep for the flag format.
- pycryptodome provides pow, long_to_bytes, and the AES modes needed for the
full solve.