│ Category: Cryptography
│ Difficulty: Easy
│ Points: 100
│ Author: Imattas aka Zemi
────────────────────────────────────────────────────────────────────────────────
--[ Challenge Description ]--
│ A message has been encrypted using RSA. The public key is gone... but someone
might have been careless with the private key. (RSA with key hidden via
steganography)
────────────────────────────────────────────────────────────────────────────────
--[ Recon / Initial Analysis ]--
This is a combination challenge involving two techniques:
1. Steganography -- The RSA private key is hidden inside an image file
2. RSA decryption -- Once the private key is recovered, use it to decrypt the
ciphertext
The challenge provides an image file (likely PNG or JPEG) and an encrypted
ciphertext file. The RSA private key has been embedded in the image using
steganography.
Common steganography techniques to check:
- LSB (Least Significant Bit) encoding in PNG images -- use zsteg to detect
- Steghide for JPEG images -- use steghide extract -sf image.jpg
- Metadata/EXIF -- check with exiftool
- Appended data -- use binwalk to find data appended after the image
- String search -- use strings to look for PEM-formatted key data
The private key will likely be in PEM format:
-----BEGIN RSA PRIVATE KEY-----
...base64-encoded key data...
-----END RSA PRIVATE KEY-----
────────────────────────────────────────────────────────────────────────────────
--[ Vulnerability / Observation ]--
The careless mistake is that the RSA private key itself has been embedded in the
image. Whoever can extract it from the image can decrypt the ciphertext directly
— no factoring or public-key recovery needed. Once the PEM-formatted private key
is recovered, it can be used with:
- Python's pycryptodome library
- OpenSSL command line: openssl rsautl -decrypt -inkey private.pem -in
ciphertext.bin
- Python's cryptography library
────────────────────────────────────────────────────────────────────────────────
--[ Exploitation / Solution ]--
1. Examine the provided image with steganography tools:
- zsteg image.png (for PNG)
- steghide extract -sf image.jpg (for JPEG)
- binwalk image.png (check for embedded files)
- strings image.png | grep -i "BEGIN" (look for PEM headers)
- exiftool image.png (check metadata)
2. Extract the RSA private key from the image.
3. Save the key to a file (e.g., private.pem).
4. Decrypt the ciphertext using the private key.
5. The decrypted plaintext contains the flag.
────────────────────────────────────────────────────────────────────────────────
--[ Full Exploit Script ]--
-- python --
#!/usr/bin/env python3
"""
StegoRSA - picoCTF 2026 (Cryptography, 100 pts)
The RSA private key is hidden inside an image via steganography.
We need to:
1. Extract the private key from the image
2. Use it to decrypt the ciphertext
3. Recover the flag
Usage:
python3 solve.py [image_file] [ciphertext_file]
e.g.: python3 solve.py stego.png ciphertext.txt
If no files are given, the script searches the current directory.
"""
import sys
import os
import re
import subprocess
import base64
import argparse
import glob
def find_challenge_files():
"""Search current directory for image and ciphertext files."""
images = []
ciphertexts = []
for f in os.listdir("."):
ext = f.lower().split(".")[-1] if "." in f else ""
if ext in ("png", "jpg", "jpeg", "bmp", "gif", "tiff"):
images.append(f)
elif ext in ("txt", "enc", "bin", "ct", "encrypted"):
ciphertexts.append(f)
elif f in ("ciphertext", "flag.enc", "message.enc", "encrypted"):
ciphertexts.append(f)
return images, ciphertexts
def extract_key_zsteg(image_file):
"""Try extracting hidden data using zsteg (PNG/BMP)."""
print(f"[*] Trying zsteg on {image_file}...")
try:
result = subprocess.run(
["zsteg", image_file, "--all"],
capture_output=True, text=True, timeout=30
)
output = result.stdout + result.stderr
# Look for PEM key markers
if "BEGIN" in output and "PRIVATE" in output:
print("[+] zsteg found potential key data!")
# Try to extract with specific channel
for channel in ["b1,rgb,lsb,xy", "b1,r,lsb,xy", "b1,bgr,lsb,xy",
"b2,rgb,lsb,xy", "b1,rgba,lsb,xy"]:
r2 = subprocess.run(
["zsteg", image_file, "-E", channel],
capture_output=True, timeout=30
)
data = r2.stdout.decode(errors="ignore")
if "BEGIN RSA PRIVATE KEY" in data or "BEGIN PRIVATE KEY" in data:
return extract_pem_key(data)
# Also try default extraction
result2 = subprocess.run(
["zsteg", image_file],
capture_output=True, text=True, timeout=30
)
for line in result2.stdout.split("\n"):
if "BEGIN" in line or "PRIVATE" in line:
# Extract the channel info and use it
parts = line.split(":")
if len(parts) >= 2:
channel = parts[0].strip()
r3 = subprocess.run(
["zsteg", image_file, "-E", channel],
capture_output=True, timeout=30
)
data = r3.stdout.decode(errors="ignore")
key = extract_pem_key(data)
if key:
return key
return None
except FileNotFoundError:
print("[!] zsteg not installed (gem install zsteg)")
return None
except Exception as e:
print(f"[!] zsteg error: {e}")
return None
def extract_key_steghide(image_file, passphrase=""):
"""Try extracting hidden data using steghide (JPEG)."""
print(f"[*] Trying steghide on {image_file}...")
try:
result = subprocess.run(
["steghide", "extract", "-sf", image_file, "-p", passphrase, "-f", "-xf", "-"],
capture_output=True, timeout=30
)
data = result.stdout.decode(errors="ignore")
if "BEGIN" in data and "PRIVATE" in data:
print("[+] steghide found key data!")
return extract_pem_key(data)
return None
except FileNotFoundError:
print("[!] steghide not installed")
return None
except Exception as e:
print(f"[!] steghide error: {e}")
return None
def extract_key_strings(image_file):
"""Try finding PEM key data using strings."""
print(f"[*] Searching strings in {image_file}...")
try:
result = subprocess.run(
["strings", "-n", "10", image_file],
capture_output=True, text=True, timeout=30
)
return extract_pem_key(result.stdout)
except Exception as e:
print(f"[!] strings error: {e}")
return None
def extract_key_binwalk(image_file):
"""Try finding embedded files using binwalk."""
print(f"[*] Trying binwalk on {image_file}...")
try:
result = subprocess.run(
["binwalk", "--extract", "--directory=/tmp/stego_extract", image_file],
capture_output=True, text=True, timeout=30
)
# Check extracted files for keys
extract_dir = f"/tmp/stego_extract"
if os.path.exists(extract_dir):
for root, dirs, files in os.walk(extract_dir):
for f in files:
filepath = os.path.join(root, f)
try:
with open(filepath, "r") as fh:
content = fh.read()
key = extract_pem_key(content)
if key:
print(f"[+] Found key in binwalk extract: {filepath}")
return key
except Exception:
pass
return None
except FileNotFoundError:
print("[!] binwalk not installed")
return None
except Exception as e:
print(f"[!] binwalk error: {e}")
return None
def extract_key_exiftool(image_file):
"""Check image metadata for hidden key data."""
print(f"[*] Checking EXIF metadata of {image_file}...")
try:
result = subprocess.run(
["exiftool", image_file],
capture_output=True, text=True, timeout=30
)
return extract_pem_key(result.stdout)
except FileNotFoundError:
print("[!] exiftool not installed")
return None
except Exception as e:
print(f"[!] exiftool error: {e}")
return None
def extract_key_raw(image_file):
"""Read the raw file and search for PEM key data."""
print(f"[*] Searching raw bytes of {image_file}...")
with open(image_file, "rb") as f:
data = f.read()
text = data.decode(errors="ignore")
return extract_pem_key(text)
def extract_pem_key(text):
"""Extract a PEM-formatted private key from text."""
# Match RSA PRIVATE KEY or PRIVATE KEY
patterns = [
r"(-----BEGIN RSA PRIVATE KEY-----[\s\S]*?-----END RSA PRIVATE KEY-----)",
r"(-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----)",
r"(-----BEGIN EC PRIVATE KEY-----[\s\S]*?-----END EC PRIVATE KEY-----)",
]
for pattern in patterns:
match = re.search(pattern, text)
if match:
key_pem = match.group(1)
# Clean up: ensure proper line breaks
key_pem = key_pem.replace("\\n", "\n")
return key_pem
return None
def decrypt_rsa_pycryptodome(key_pem, ciphertext_data):
"""Decrypt ciphertext using pycryptodome."""
try:
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, PKCS1_v1_5
key = RSA.import_key(key_pem)
print(f"[*] RSA key: {key.size_in_bits()}-bit, n={str(key.n)[:40]}...")
# Try PKCS1_OAEP first
try:
cipher = PKCS1_OAEP.new(key)
plaintext = cipher.decrypt(ciphertext_data)
return plaintext
except (ValueError, TypeError):
pass
# Try PKCS1_v1_5
try:
cipher = PKCS1_v1_5.new(key)
plaintext = cipher.decrypt(ciphertext_data, sentinel=b"DECRYPTION_FAILED")
if plaintext != b"DECRYPTION_FAILED":
return plaintext
except (ValueError, TypeError):
pass
# Try raw/textbook RSA (no padding)
try:
c = int.from_bytes(ciphertext_data, "big")
m = pow(c, key.d, key.n)
plaintext = m.to_bytes((m.bit_length() + 7) // 8, "big")
return plaintext
except Exception:
pass
return None
except ImportError:
return None
def decrypt_rsa_openssl(key_pem, ciphertext_file):
"""Decrypt using OpenSSL command line."""
key_file = "/tmp/stego_private.pem"
with open(key_file, "w") as f:
f.write(key_pem)
# Try PKCS1 OAEP
for padding in ["-oaep", "", "-raw"]:
try:
cmd = ["openssl", "rsautl", "-decrypt", "-inkey", key_file, "-in", ciphertext_file]
if padding:
cmd.append(padding)
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode == 0 and result.stdout:
return result.stdout
except Exception:
pass
# Try with pkeyutl (newer OpenSSL)
for padding in ["-pkeyopt", ""]:
try:
cmd = ["openssl", "pkeyutl", "-decrypt", "-inkey", key_file, "-in", ciphertext_file]
result = subprocess.run(cmd, capture_output=True, timeout=10)
if result.returncode == 0 and result.stdout:
return result.stdout
except Exception:
pass
os.unlink(key_file)
return None
def decrypt_rsa_cryptography(key_pem, ciphertext_data):
"""Decrypt using the cryptography library."""
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
private_key = serialization.load_pem_private_key(
key_pem.encode(), password=None
)
# Try OAEP
try:
plaintext = private_key.decrypt(
ciphertext_data,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
return plaintext
except Exception:
pass
# Try OAEP with SHA1
try:
plaintext = private_key.decrypt(
ciphertext_data,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA1()),
algorithm=hashes.SHA1(),
label=None
)
)
return plaintext
except Exception:
pass
# Try PKCS1v15
try:
plaintext = private_key.decrypt(
ciphertext_data,
padding.PKCS1v15()
)
return plaintext
except Exception:
pass
return None
except ImportError:
return None
def main():
parser = argparse.ArgumentParser(description="StegoRSA solver - picoCTF 2026")
parser.add_argument("image_file", nargs="?", help="Image file with hidden key")
parser.add_argument("ciphertext_file", nargs="?", help="Encrypted ciphertext file")
args = parser.parse_args()
print("=" * 60)
print(" StegoRSA - picoCTF 2026 Solver")
print("=" * 60)
print()
# Find files if not specified
image_file = args.image_file
ciphertext_file = args.ciphertext_file
if not image_file or not ciphertext_file:
images, ciphertexts = find_challenge_files()
if not image_file and images:
image_file = images[0]
print(f"[*] Auto-detected image: {image_file}")
if not ciphertext_file and ciphertexts:
ciphertext_file = ciphertexts[0]
print(f"[*] Auto-detected ciphertext: {ciphertext_file}")
if not image_file:
print("[!] No image file found. Usage: python3 solve.py <image> <ciphertext>")
sys.exit(1)
if not ciphertext_file:
print("[!] No ciphertext file found. Usage: python3 solve.py <image> <ciphertext>")
sys.exit(1)
# ---- Step 1: Extract private key from image ----
print(f"\n[*] === Step 1: Extract Private Key from {image_file} ===\n")
key_pem = None
extractors = [
extract_key_raw, # Check raw bytes first (fast)
extract_key_strings, # strings command
extract_key_exiftool, # EXIF metadata
]
# Add format-specific extractors
ext = image_file.lower().split(".")[-1] if "." in image_file else ""
if ext in ("png", "bmp"):
extractors.insert(1, extract_key_zsteg)
if ext in ("jpg", "jpeg"):
extractors.insert(1, extract_key_steghide)
extractors.append(extract_key_binwalk) # binwalk last (creates files)
for extractor in extractors:
key_pem = extractor(image_file)
if key_pem:
print(f"\n[+] Private key extracted successfully!")
print(f"[+] Key preview: {key_pem[:80]}...")
break
if not key_pem:
print("[!] Could not extract private key from image")
print("[!] Try manually with: zsteg, steghide, binwalk, or strings")
sys.exit(1)
# Save the extracted key
with open("extracted_private.pem", "w") as f:
f.write(key_pem)
print("[*] Key saved to: extracted_private.pem")
# ---- Step 2: Read and decrypt ciphertext ----
print(f"\n[*] === Step 2: Decrypt {ciphertext_file} ===\n")
with open(ciphertext_file, "rb") as f:
ct_raw = f.read()
# Check if ciphertext is base64-encoded
try:
ct_text = ct_raw.decode().strip()
ct_data = base64.b64decode(ct_text)
print(f"[*] Ciphertext appears to be base64-encoded ({len(ct_data)} bytes decoded)")
except Exception:
ct_data = ct_raw
print(f"[*] Ciphertext is raw binary ({len(ct_data)} bytes)")
# Also try if it might be hex-encoded
try:
ct_text = ct_raw.decode().strip()
ct_hex = bytes.fromhex(ct_text)
if len(ct_hex) > 0:
print(f"[*] Also trying hex-decoded interpretation ({len(ct_hex)} bytes)")
except Exception:
ct_hex = None
# Try decryption methods
plaintext = None
for ct in [ct_data, ct_hex] if ct_hex else [ct_data]:
if ct is None:
continue
# Try pycryptodome
plaintext = decrypt_rsa_pycryptodome(key_pem, ct)
if plaintext:
print("[+] Decrypted with pycryptodome!")
break
# Try cryptography library
plaintext = decrypt_rsa_cryptography(key_pem, ct)
if plaintext:
print("[+] Decrypted with cryptography library!")
break
# Try OpenSSL as fallback
if not plaintext:
plaintext = decrypt_rsa_openssl(key_pem, ciphertext_file)
if plaintext:
print("[+] Decrypted with OpenSSL!")
if plaintext:
decoded = plaintext.decode(errors="replace")
print(f"\n[+] Decrypted message: {decoded}")
flag_match = re.search(r"picoCTF\{[^}]+\}", decoded)
if flag_match:
print(f"\n{'=' * 60}")
print(f" FLAG: {flag_match.group()}")
print(f"{'=' * 60}")
else:
print("[*] Flag format not found in decrypted text")
print(f"[*] Full decrypted output: {decoded}")
else:
print("[!] Could not decrypt the ciphertext")
print("[!] The private key might need different handling")
print("[!] Try: openssl rsautl -decrypt -inkey extracted_private.pem -in " + ciphertext_file)
print("\n[*] Done!")
if __name__ == "__main__":
main()
────────────────────────────────────────────────────────────────────────────────
--[ Key Takeaways ]--
- "Careless with the private key" signals that the secret to recover is
embedded, not derived — find it via steganography rather than attacking RSA
math.
- Run a battery of stego tools against the image: zsteg (PNG/BMP LSB), steghide
(JPEG), binwalk (appended/embedded files), exiftool (metadata), and plain
strings/raw-byte search for PEM markers.
- A PEM private key is recognizable by its -----BEGIN ... PRIVATE KEY-----
header — grep for BEGIN/PRIVATE.
- Once you have the key, try multiple RSA padding schemes (OAEP, PKCS1 v1.5,
raw/textbook) since the challenge doesn't say which was used.
- pycryptodome, the cryptography library, and openssl rsautl/pkeyutl all decrypt
with the recovered PEM.