The TechTransThai Security Research team recently participated in SekaiCTF 2026; here is a collection of our notable solves in this event.

Migurimental (Web; Easy)

Submitted by ominachan

We’re given links to two websites: https://migurimental.chals.sekai.team and https://migurimental-2.chals.sekai.team. We are also given both sites’ source code. Both are written in Next.js 16.2.9 using the Pages Router, and each hold one half of the flag. Access to the pages containing the flag is locked behind identity checks which we will need to bypass.

The first website: checking the wrong thing

The first website has a registration and login system. When logged in, we receive two cookies:

  • session: a JSON Web Token containing information about our login session. The important fields are sub, containing our user ID, and ticketUuid, the UUID associated with our account.
  • ticket_uuid: our account’s UUID in plaintext.

These values are used in the following pages:

  • /access-card?id=...: the page we are sent to after logging in. Contains information about the account for that id, including a QR code with its UUID. The website’s Proxy1 checks that the id query is equal to session.sub to prevent looking at other people’s cards.
  • /backroom: contains the first half of the flag. Proxy checks that ticket_uuid is the same as session.ticketUuid, then the page itself checks that ticket_uuid belongs to the user with ID 1.

Hence to get the flag, we should:

  1. Bypass Proxy’s check on /access-card?id=1 and get user 1’s UUID. (We can’t simply forge session as it is signed.)
  2. Set ticket_uuid to user 1’s UUID while bypassing Proxy’s check on /backroom.

Next.js internal queries are inconsistently prioritized

Next.js has internal queries prefixed with nxtP which are used to set query values, e.g. ?nxtPid=3 sets id to 3. If both id and nxtPid are set, nxtPid overrides id in Proxy while id overrides nxtPid in the main app. If we are logged in as user 2427, we can use this query:

/access-card?nxtPid=2427&id=1

Proxy checks that its id, which is actually nxtPid, is equal to session.sub—it is—and allows the request. But then the page returns information for its id, which is 1, not 2427, and we can now decode the QR code to get user 1’s UUID.

Next.js inconsistently handles duplicate cookies

Next.js has two different runtimes: rendering pages uses the standard Node.js runtime, while Proxy uses the Edge runtime. When there are duplicate cookie names, the Edge runtime will get the last value while the Node.js runtime will get the first value. We can send a request to /backroom with the following cookies:

Cookie: session=<our session>;
        ticket_uuid=<user 1's UUID>;
        ticket_uuid=<our UUID>

Proxy receives ticket_uuid as our UUID, matching session.ticketUuid, and allows the request, while the main page receives ticket_uuid as user 1’s UUID, verifies that it corresponds to user 1, and gives us the first half of the flag.

The second website: not checking everything

The second website has a very simple Proxy that rejects all requests to / not coming from IP address 1.3.3.7. However, its next.config.js has assetPrefix: '/cdn', which makes Next.js add an internal rewrite: /cdn/_next/... becomes /_next/..., and this rewrite runs after Proxy. By making a request to:2

/cdn/_next/data/<buildId>/index.json

Proxy’s matcher does not include this in / and does not try to block the request. But this is then rewritten to /_next/data/<buildId>/index.json, which fetches serverside data for the / page, including the second half of the flag.

Flag: SEKAI{7h3_l33k_15_b4ck_7h3_cr0wd_15_ch33r1ng_4nd_7h3_c0nc3r7_c4n_f1n4lly_b3g1n_m1ku_m1ku_b34mmmmmmmmmmmm}

Takeaway: check your checks

In order to get each half of the flag, we exploited various Next.js quirks to bypass the intended security checks. In the first website, we made Proxy check values that are different to what the page actually receives, while in the second website we simply skipped Proxy at the matching step. Here are some things the first website should have done instead:

  1. Instead of making /access-card have an id query, simply make it return the card corresponding to session.sub without any query required. There is no reason for the id query to exist if no user should be accessing anyone else’s card.
  2. The ticket_uuid cookie is superfluous when session.ticketUuid needs to exist anyway. The /backroom page should check session.ticketUuid directly.
  3. Because the previous two changes will make Proxy’s current rules unnecessary, Proxy’s role can be reduced to checking for these two pages whether a session token exists and is valid.

And for the second website: if access was intended to be gated at the IP address level, then either a rule should have been added at the nginx level (since nginx was used as a reverse proxy), or Proxy’s matcher should have been set to block all requests except those required to serve basic site metadata and render the 403 page; an example is given in the Next.js documentation.

ppp (Binary Exploitation; Easy)

Submitted by ominachan

We’re given source code for a program afc_list, a simple Apple File Conduit (AFC) client, running on a remote server. AFC is the protocol used to transfer files to and from iOS devices. afc_list prompts the user for commands, then communicates with a device using AFC to perform the specified command—except the “device” is standard input and output, so we are acting as both the end user and the iOS device. Our goal is to execute another binary on the server, /readflag, which prints out the flag.

These two programs on the server sit in a Docker container running Ubuntu 20.04, with glibc 2.31. Address space randomization is intentionally disabled, and the afc_list program is compiled with -no-pie—disabling creating a position-independent executable—hence the executable loads in at a fixed position. This signals that the exploit to be performed involves manipulating memory addresses; furthermore, the program is compiled with stack-protector-all, indicating this memory manipulation must happen on the heap.

The afc_list program itself is primarily glue code for interacting with libimobiledevice, and more specifically its AFC code at src/afc.c. We can notice a problem in afc_receive_data(), which gets data from an AFC packet, at lines 308–314:

entire_len = (uint32_t)header.entire_length - sizeof(AFCPacket);
this_len = (uint32_t)header.this_length - sizeof(AFCPacket);

buf = (char*)malloc(entire_len);
if (this_len > 0) {
        recv_len = 0;
        err = service_to_afc_error(service_receive(client->parent, buf, this_len, &recv_len));

An AFC packet contains two length fields in the header: entire_length and this_length; subtracting the packet header size gives entire_len and this_len respectively. This snippet allocates a buffer for entire_len, but reads data of this_len into it. Crucially, there is no check that this_length is not greater than entire_length, so an attacker can create a packet with a small entire_length and cause afc_receive_data() to write data into memory adjacent to the allocated buffer. This is the basis for our attack.

Predictably overwriting memory with the tcache

Memory dynamically allocated by glibc is divided into chunks, and you get one chunk for every malloc() call. On x86-64, these chunks have a minimum size of four 64-bit words, or 32 bytes (64 bits is eight bytes). Every chunk has its first word reserved as the header for glibc’s own use, so the smallest chunk with 32 bytes has a usable memory area of 24 bytes.

glibc has an optimization where memory that is freed is not returned to the system but instead kept around for future use. The tcache is one of these optimizations and contains recently freed chunks stored in one of many lists, with one list for each chunk size. These are singly-linked lists where the first word of a chunk’s usable area is a pointer to the next chunk in the list. Therefore if we know a chunk in the tcache, B, is in the memory location immediately after a chunk we have access to, A, writing data past A’s bounds can cause B’s pointer to change from pointing to the next chunk, C, to instead any arbitrary memory address. Hence the second next “chunk” allocated from that list in the tcache will not be a free chunk but instead an attacker-chosen area of memory.

To start our attack, then, we need to make sure there are some chunks in the tcache. Sending a ls command and responding with small dummy names like "AAAA\0BBBB\0CCCC\0" will do the trick: the program will create a new string for each token, which will allocate and then free some chunks with the minimum size of 32 bytes. Therefore we issue a ls / and then respond with this packet:3

LocationOffsetContents
Header0x00CFA6LPAA (magic string)
0x080x37 (55 bytes entire length)
0x100x37 (55 bytes this length)
0x180x01 (1st packet)
0x200x02 (data response)
Body0x28AAAA\0BBB
0x30B\0CCCC\0

Now we can forge a malicious packet. We give this packet an entire_length of 64 bytes—with “24” bytes for the body, fully filling a 32-byte chunk—and a this_length of 80 bytes—entire_length plus 16 extra bytes (one word for the header and one word for the pointer of the next chunk).

LocationOffsetContents
Header0x00CFA6LPAA (magic string)
0x080x40 (64 bytes entire length)
0x100x50 (80 bytes this length)
0x180x02 (2nd packet)
0x200x01 (status response)
Body (in buffer)0x280x00 (dummy data of zeros)
0x300x00
0x380x00
Body (next chunk)0x400x21 (chunk header)
0x48Malicious memory address

Hijacking execution by overwriting free()

Before we can send this packet, we need to figure out what memory address to use to continue our attack. More specifically, we need to be able to run the system() function to execute a shell command of /readflag sekai ppp: executing the /readflag binary with the required arguments of sekai and ppp. So we need to hijack some function that runs on a user input string, say the strings allocated by the ls command.

We have exactly this in __free_hook, a deprecated debugging variable removed in glibc 2.34, though fortunately it still exists in glibc 2.31 running in the container. __free_hook specifies the address of a function that will be run on all inputs to free(), intended to be used for debugging memory allocation calls. If we specify the address of __free_hook in our malicious packet, we can later overwrite __free_hook to be any function we choose and it will run whenever memory is deallocated. Therefore we can now issue a mkdir /a command, and then respond with the packet shown before, with the pointer set to 0x7ffff7f53e48 (the address of __free_hook).4

Finally we execute the attack. We run another ls / command, and then respond with this packet:5

LocationOffsetContents
Header0x00CFA6LPAA (magic string)
0x080x43 (67 bytes entire length)
0x100x43 (67 bytes this length)
0x180x03 (3rd packet)
0x200x02 (data response)
Body0x28/readfla
0x30g sekai
0x38ppp\0 (our command) + 0xf7db7290
0x400x7fff (address of system()) + \0

Walking through the execution:

  1. Once the body is read, the ls command separates it into tokens. It allocates one 32-byte chunk (the one with the next pointer overwritten) for the string "/readflag sekai ppp".
  2. Then it tries to allocate a second 32-byte chunk for the next token, but due to our previous packet this “chunk” turns out to be the location of __free_hook. The program does not know this, so it writes the address of system() into __free_hook. This means the program will attempt to execute every chunk of memory about to be freed as a shell command.
  3. Once the program has printed out both of these tokens, it will then free the strings it just allocated. This means it will free "/readflag sekai ppp", which means it will run system("/readflag sekai ppp"). This will now run the /readflag binary with the correct arguments and print out the flag.

Takeaway: buffer overflow

This challenge demonstrates a classic buffer overflow bug, a major class of memory vulnerabilities made infamous by the OpenSSL Heartbleed bug 12 years ago. This underscores the importance of carefully checking all logic related to memory allocation, or perhaps switching to a memory-safe language like Rust.

PP Farming (Blockchain; Easy)

Submitted by lily

We’re given a smart contract, PerformancePointATM, initially funded with 10 ether, and we have to drain its balance until it is empty. The contract accepts payments with an ability to specify a recipient:

function donatePP(address _to) public payable {
    scores[_to] += msg.value;
}

scores is a mapping(address => uint256), so it keeps track of how much ether a given address is owed. The recipient can then withdraw their amount by calling this function:

function withdrawPP() public {
    uint256 score = scores[msg.sender];
    require(score > 0, "Nothing to withdraw");
    (bool result, ) = msg.sender.call{value: score}("");
    require(result, "Transfer failed");
    scores[msg.sender] = 0;
}

It transfers the amount the caller is owed, and once the transfer is successful the caller’s balance with the contract is zeroed out. The crucial part is that the transfer happens before zeroing out the caller’s balance. If the recipient is a smart contract, when the external transfer is initiated, execution of withdrawPP() is suspended and control flow is handed over to the recipient’s receive() function. This means it is possible to set up a smart contract that, when it receives a transfer, immediately calls into withdrawPP() again, with the new withdrawPP() call still believing the recipient has a balance with the contract:

receive() external payable {
    if (address(atm).balance >= unit) {
        atm.withdrawPP();
    }
}

Here, unit is the amount of ether used to start the attack:

uint256 unit;

function attack() external payable {
    unit = msg.value;
    atm.donatePP{value: unit}(address(this));
    atm.withdrawPP();
}

This leads to a recursive chain of calls between withdrawPP() and receive(), until the balance of PerformancePointATM is less than unit, at which point execution terminates with the final receive(). As long as unit divides the initial balance of the ATM, e.g. 1 ether, we will fully drain it.

Once we’re done, we can then go to the challenge website to claim our flag: SEKAI{3Z_re3ntr4ncy_atTack5}.

Takeaway: reentrancy

This is a well-known attack known as a reentrancy attack, where an interaction from contract A to B allows B to call back into contract A. To prevent this, it is important to write code in a Checks-Effects-Interactions pattern, where all Checks are performed first, then Effects on the contract’s state, and then finally external Interactions. If the creator of PerformancePointATM followed this pattern, the first call back into withdrawPP() from our receive() would have simply rejected the repeat withdrawal as the balance is correctly set to zero.

That’s all!

That’s what we’ve been up to for now. Stay tuned for our next blog post via RSS or Telegram!


  1. Proxy is Next.js code that handles requests before passing them along to the main app. The website source calls this middleware, which is the old name. ↩︎

  2. buildId is a string unique to each build of the application, and can be found in the __NEXT_DATA__ appended to the page /rejected: "buildId": "VlOPiWVknxOpNtTjt6ctd"↩︎

  3. The packet header is 40 bytes long, and our body is 15 bytes, so the length is 55 bytes. In this and the following tables, each row in the table corresponds to one word of eight bytes. The final row may be less than eight bytes according to the packet length. ↩︎

  4. In this container glibc has a base address of 0x7ffff7d65000, and __free_hook has an offset of 0x1eee48↩︎

  5. system()’s address is 0x7ffff7db7290—an offset of 0x52290 from the base address. x86-64 is little-endian, so the least significant bits come first. There normally should be two zero bytes of padding to complete a 64-bit word, i.e. 0x00007ffff7db7290, but ls interprets zero bytes as a delimiter, so we omit them. ↩︎