Writeup author: fsharp
(The heading above can't show more than 3 question marks so this will have to doโฆ)
I played IrisCTF 2025 with DeadSec
and checked out a variety of challenges. This writeup is on the reverse engineering challenge I solved during the CTF, which is ????????
and has only 7 solves. We were the 6th team to solve it.
Author: nope
where is this challenge? what is this challenge?
(must use webgl version to solve, you may need to shift+refresh to clear your cache to see the updated changes)
(Link provided: https://2025.irisc.tf/wgl)
To add some context, this year there were 2 versions of the IrisCTF website that players can navigate to: a WebGL version containing really cool graphics and animations, and a regular version that was also present in the previous 2 iterations of IrisCTF.
The description hints that the challenge is only solvable in the WebGL version of the website, and all we get is its link. Having participated in the first IrisCTF, I know that there was a challenge based on reversing a scoreboard Easter egg hidden in the CTF website, and I believed this one to be similar.
Since the scoreboard Easter egg was hidden in a JavaScript (JS) file, I logged into the WebGL version of the website, cleared the browser cache, and scoured its web source. The following stood out to me immediately:
The first file (71a983c3dac9500a8a94a3f60d56302d.js
) looks like this:
The loadWasm()
function fetches a WebAssembly (WASM) file located at /static/bg/${await sha1Hex('mind')}.wasm
, loads it, and sets window['mind']
to a dictionary containing functions exported from that file. await sha1Hex('mind')
calculates the SHA-1 hash of the byte-string mind
, turns it into hexadecimal characters, and returns the first 32 characters, which is d42c0112962be8a77d278ebe747514e5
.
The second file (f.min.js
) is a minified JS file. The string mind
is present in several functions:
Nb(a, b, c)
performs the following if the cookie data for the CTF website contains the string chal
:
window.mind
is empty, loadWasm()
is called. This is for window.mind
to contain references to exported WASM functions.window.mind.perception()
returns -1
, a BMP image located at /static/bg/${await Mb()}.bmp
is retrieved, and a buffer containing the image's bytes is passed into window.mind.recognition()
.window.mind.perception()
doesn't return -1
, window.mind.consciousness(c)
is called and its result is assigned to c
.c
isn't null
, cb(c, a, b)
is called.await Mb()
is essentially await sha1Hex('feeling')
, which returns b3b6db05f1888b1603560e78a9564d9c
. bb()
seems to be a function that checks whether an entered flag is correct by passing it into window.mind.awareness()
, and cb()
seems to override the flag submission mechanism of a challenge based on specific conditions being met.
At this point, I was certain this challenge involves the JS, WASM and BMP files that I've written about.
I downloaded both /static/bg/d42c0112962be8a77d278ebe747514e5.wasm
and /static/bg/b3b6db05f1888b1603560e78a9564d9c.bmp
, then loaded the WASM file into Ghidra with the Ghidra WASM plugin to decompile it.
The BMP image looks like this:
Huh. Interesting.
perception()
and reality()
return the global variables global_13
and global_14
respectively:
However, the exported reality()
function from the first JS file returns __liftString(exports.reality() >>> 0)
. Perhaps global_14
is a pointer to a string? The second JS file contains:
โฆso window.mind.reality()
probably returns a string that is used for the flag of this challenge. It is only called when 14 === a.id
and window.mind.perception()
returns a number larger than 4. Moreover, if 1E7 < a.id
and window.mind.perception()
returns a number less than 5, the flag submission mechanism gets overriden with the bb()
function. Checking the HTML source of the website, the challenge with ID 14 is ????????
, so maybe its description will contain the flag at one point.
consciousness()
takes an integer argument and does something with it:
2 kinds of values (0x660
and uVar1
) are returned depending on whether specific conditions are satisfied. For uVar1
to be returned, the conditions to satisfy are:
iVar2
is not equal to 0.global_13
is 0, 1, 2, 3 or 4, the argument must be 3, 5, 6, 1 and 8 respectively.global_13
must not be greater than 4.Where do 3, 5, 6, 1 and 8 come from? Going back to d.min.js
, the argument passed into window.mind.consciousness()
can be traced back to its origin:
It turns out the argument is a number representing the challenge category currently selected in the website! 3, 5, 6, 1 and 8 correspond to forensics, networks, open-source intelligence, binary exploitation, and reverse engineering respectively. This matches the implicit meaning of the catIdx
argument ('category index') for consciousness()
in the first JS file analyzed.
What about recognition()
? The first JS file names the argument as jsSecretData
, so maybe the BMP image contains hidden data?
Ghidra decompiles the function as follows:
There's memory copying involved, and the global variables global_10
to global_13
are set. From this, I guess that either global_10
or global_12
stores a pointer to a byte buffer containing the BMP image's bytes.
To validate this, I use the Chrome browser's DevTools functionality to inspect the WASM's memory. The DevTools documentation contains good tutorials on doing so, which I'll link here and here. In a nutshell:
chal
and refreshing the page.return
opcode used by recognition()
, which is located at 0x208d
.globals
dropdown shows the values of global variables, and the memories
dropdown has a button that opens the memory inspector. Click on that button.$global10
or $global12
.For me, $global10 = 0x598e0
and $global12 = 0xa99a0
.
$global10
is quite close to the start of the BMP image bytes:
The BMP image is located at 0x59900
. What does $global12
point to?
Hmm. Doesn't seem like the variables are usefulโฆ but wait! Looking at them again, both $global10
and $global12
point to 00 99 05 00
, and interpreting those bytes as a 4-byte little-endian integer gives 0x59900
, which is where the BMP image is! So, both global variables are pointers to a pointer to the BMP image.
Now let's take a look at awareness()
. My previous analysis showed that it takes in an entered flag.
The flag is passed into unnamed_function_31()
, and awareness()
returns whatever is returned by that function. Also, from the first JS file, window.mind.awareness()
returns exports.awareness(str) != 0
. It's likely unnamed_function_31()
returns a boolean depending on whether the flag's correct.
So what does that function decompile into?
From this, I noticed the following:
unnamed_function_28(global_12, <something>)
. They are passed into unnamed_function_29()
and unnamed_function_30()
.unnamed_function_31()
to return 1
, *(uint *)(param1 - 4) >> 1 == 0xc
and iVar4 != 0
. global13
is also incremented by 1 if these conditions are satisfied.iVar4
is initialized as unnamed_function_30(param1,param2,uVar3,param3,param4,param5)
and is modified in a for
loop. The only way for iVar4
to not become 0 is if its initialized value is not 0, (uVar5 & 0x100) == 0
, and (uVar6 & 0x100) == 0
.To summarize what I've found so far:
window.mind.perception()
returns global_13
, which should range from -1 to 5. When it's -1, the BMP image isn't loaded yet. When it's 0, the BMP image is loaded. When it's 5, the flag should be displayed in the description of the ????????
challenge. When it's between 0 and 4 (inclusive), the flag submission mechanism gets overriden for certain challenges.window.mind.reality()
should return a string used for the flag of this challenge.window.mind.consciousness()
accepts a challenge category index and returns something that depends on both the inputted index and global_13
.window.mind.recognition()
accepts a byte buffer to the BMP image's bytes, and sets both global_10
and global_12
to values that point to a pointer to the start of those bytes.window.mind.awareness()
is called from the modified flag submission mechanism and accepts an entered flag. It returns a boolean that depends on both global_13
and the inputted flag. If it returns true
, global_13
is incremented by 1.From all of this information, there seems to be 5 stages that need to be passed, with each stage requiring a specific flag.
The goal now is to focus on understanding what unnamed_function_31()
does to solve this challenge.
There must be a place to enter a flag for each stage. From my previous analysis, I noticed that 5 challenge categories are referenced, and the first one is forensics.
After loading the WASM and heading to that category, I see this at the bottom of the page:
That's probably it! At this point, I think I have to solve a special challenge from the forensics, networks, open-source intelligence, binary exploitation, and reverse engineering categories in that order.
At the start of unnamed_function_31()
, there are several unnamed_function_28(global_12, <something>)
calls. What do they return? If I set breakpoints at 0x1da1
, 0x1db2
and 0x1dc3
, which are opcodes right after those calls, then entered a flag, I can look at the stack and see that they return 0x8a
, 0x140
and 0x100
respectively. The decompilation of the function is:
Given that param1
is global_12
and it's known that it's a pointer to a pointer to the BMP image bytes, param2
might be a byte offset from the start of the image. Indeed, checking the BMP image in a hex editor confirms this:
So, unnamed_function_28()
returns a 4-byte little-endian integer located at a offset into a byte buffer.
Looking up the BMP image format, 0x8a
is the byte offset where the image's pixel data is located, while 0x140
and 0x100
are the image's width and height respectively.
unnamed_function_29()
seems to do something similar:
Checking all calls to unnamed_function_29()
, it can be found that param3
, param4
and param5
always equal to the image width, image height, and byte offset to pixel data respectively. As pixel data is stored bottom-up, this function is retrieving a 4-byte little-endian integer from the pixel data, with param1
and param2
being the X and Y coordinates of the pixel to read.
The first usage of this function is unnamed_function_29(0,global_13,param3,param4,param5)
, so it's retrieving a pixel at the leftmost column of the image. Since global_13
ranges from 0 to 4, it will retrieve the 1st to 5th pixels of that column for each stage. Going back to unnamed_function_31()
's decompilation, it can be seen that not all 4 bytes of the pixel read are used:
param2
is the second least-significant pixel byte (green channel), while uVar3
becomes the least-significant pixel byte (blue channel). The subsequent unnamed_function_29()
calls show that they are used as X and Y coordinate offsets into the pixel data!
From my previous analysis, 2 conditions for unnamed_function_31()
to return true
are (uVar5 & 0x100) == 0
and (uVar6 & 0x100) == 0
. It is now clear that this means the least-significant bit of the green channel of 2 * 6 * 6 = 72 specific pixels must be 0. Another condition to satisfy is *(uint *)(param1 - 4) >> 1 == 0xc
. Breakpointing at 0x1df0
, which is where this comparison is being done, then entering a random flag, it can be seen that this condition means the inputted flag must be exactly 12 characters long.
So all that's left to analyze now is unnamed_function_30()
. What does it do?
There's a lot going on here, so I'll go through this slowly.
For unnamed_function_31()
to return true
, this function must return 1
, and line 11 shows that uVar10
must be greater than 11. It is initialized to 0 in line 7 and gets incremented by 1 in line 97, so its value ranges from 0 to 12. In addition, line 17 assigns an unsigned short from param1
(the entered flag) to plVar2
. So, uVar10
is an index for the flag string, and plVar2
is one of the flag characters.
u_abcdefghijklmnopqrstuvwxyz012345_ram_00001090
points to a Unicode string abcdefghijklmnopqrstuvwxyz0123456789
. Every character in that string is 2 bytes long, which matches the width of an unsigned short. Lines 22 to 65 and 101 to 106 look intimidating, but looking closer, a few observations can be made:
plVar1
is assigned one of the entered flag characters.plVar6
is assigned something from the Unicode string. uVar9
is probably a character index for that string as line 105 shows it being incremented by 1, so plVar6
is one of the characters from that string.plVar1
and plVar6
are checked to see if they're equal.It is very likely that these lines are checking if the entered flag character is inside the Unicode string. If it isn't, the function returns 0
; otherwise, lines 66 to 97 are executed.
unnamed_function_16()
can be called at most 6 times per flag character. Some numbers are calculated from uVar9
(the index of the flag character in the Unicode string) and uVar10
(the index of the character in the entered flag), which are used to determine which unnamed_function_16()
calls to make and are passed into those calls as arguments. That function's decompilation is:
unnamed_function_15()
's decompilation looks like:
This function XORs a pixel value by 0x100. Since the second least-significant byte of a pixel is its green channel, this function is toggling the least significant bit of a pixel's green channel.
The pixel value being XOR'd is located at this byte offset in the BMP image:
param7 + ((param6 + -1) - (param2 + param4)) * param5 * 4 + (param1 + param3) * 4)
Tracing the values of these parameters, it's found that param1
and param2
are always the X and Y offsets obtained from the colour channels of the first pixel column, while param5
to param7
are always the image width, image height, and byte offset to pixel data respectively. Thus, param3
and param4
are offsets additional to the X and Y offsets from colour channels.
unnamed_function_16()
calls unnamed_function_15()
depending on the values of param3
and param4
, and both range from 0 to 5 (inclusive). Given that both parameters are passed into unnamed_function_29()
as additional X and Y offsets, it is now quite clear what kind of data is hidden inside the BMP image, and what problem this CTF challenge is actually about.
Each of the 5 stages involves the least significant bits of the green channel from 72 specific pixels in the BMP image. The goal is to get all of these bits set to 0, and the inputted flag controls which bits get toggled. The pixels from each stage are arranged as 2 grids of 6 x 6 pixels.
unnamed_function_15()
toggles the bit for 1 pixel. unnamed_function_16()
toggles bits in a 'plus' shape (+). unnamed_function_30()
controls where the 'bombs' are placed in the 2 grids, with the bomb explosions shaped like a 'plus'.
The way I viewed this is to imagine if a bomb from Bomberman were placed on the grid, the toggleable bits were flippable tiles, the grid's surrounded by indestructible walls, and the bomb's explosion flipped those tiles. Like the least powerful bomb in the game, the 'plus' explosion is 3 x 3 tiles and it only affects the tiles it covers, with the walls preventing the explosion from going outside of the grid.
To illustrate this, let's say 3 bombs were placed in a grid as follows:
The explosions will look like this:
5 + 4 + 3 = 12 tiles in that grid will be flipped.
All that remains at this point is for me to write a solver that does these:
unnamed_function_30()
and record all possible explosions for each tile.This is a massively cleaned-up version of my solver:
Running this script, I got the following output:
Let's go enter them on the website! After doing that, reloading the challenges in the reverse engineering category will show the flag: irisctf{asparagus050squar3wave44tr4incars483s1lentbird01d3cayedb0nes}
After the CTF ended, I learnt a few more things about this challenge.
unnamed_function_30
(i.e. nums0
to nums3
in my solver) comes from inverting the Cantor pairing function. This involves turning a number z
into a unique pair of numbers x, y
. In fact, the x
and y
from that section of the Wikipedia article I linked matches nums2
and nums1
respectively. Moreover, nums3
is the same as nums1
due to the bitshift. So, the x
and y
obtained from inverting the Cantor pairing function for integers from 0 to 35 are the same as nums2
and nums3
! However, neither x
nor y
are used as X or Y coordinate offsets into pixel data. They are instead used to determine where the bombs are placed in the grids.There are some more things I have (partially) figured out while reversing the WASM but haven't written much about as understanding them isn't necessary. Two of those things are the following:
unnamed_function_30()
in unnamed_function_31()
? If the entered flag is correct, that function will be called just once, which sets the least significant bits of the green channels of the 72 pixels involved to 0. However, if the flag is wrong, that function will be called twice to reset those bits back to what they were originally. This ensures that the correct flag for each stage remains the same and doesn't change due to previous incorrect attempts.export::consciousness()
return? The descriptions are made by the many function calls
I didn't include in the decompilation of that function. To sum it up, there are 8 calls to unnamed_function_25()
that create the question marks in the challenge title and description, the unnamed_function_12()
calls seem to be placing the pointers to those question mark strings at a specific index of an array of string pointers, and the unnamed_function_26()
call seems to create a joined string from that pointer array. Calling window.mind.consciousness()
in the JS console with the correct challenge category index will return a JSON string for the special challenge that will appear for that category.Lastly, I just wanted to know where exactly the 6 x 6 pixel grids from all 5 stages are in the BMP image. I'll write a script to turn those pixels red:
So what does that image look like?
Ahh, so they're at the start of each colour strip. But why is there a column of red pixels to the left of the image? It doesn't seem like it's used to mask the X and Y offsets hidden in the first 5 pixels of that columnโฆ I might never know.