This page looks best with JavaScript enabled

Grey Cat The Flag Qualifiers 2022 Writeups

Organized by NUS Greyhats from 6 June - 10 June

 ·  ☕ 40 min read  ·  🌈🕊️ rainbowpigeon

We got 4th overall and also qualified for the finals as the 3rd verified local team! My teammates were absolute beasts even though they were quite busy :) Challenge names with 🍭 are supposed to be “easier” while those with 🩸 come with first blood prizes (I managed to get 2!).

Leaderboard graph
Scoreboard
Finalists Discord announcement
First blood prizes Discord announcement
Team solves graph
Team solves breakdown
Individual solves graph
Individual solves breakdown

Details Links
CTFTime.org Event Page https://ctftime.org/event/1643/
Official Grey Cat The Flag Discord https://discord.gg/d9wbXEP2wN
Official Event Landing Page https://ctf.nusgreyhats.org/

RE

🩸 Angry Robot

You have entered a top secret robot production facilities and your clumsy friend tripped the alarm. You and your friends are about to be “decontaminated”.

Luckily, you have unpacked the authentication firmware during previous reconaissance. Can you use them to override the decontamination process?

MD5 (authentication.tar.gz) = 965b5a18735af4bbfa81879e2cedc9cc

  • rootkid

nc challs.nusgreyhats.org 10523
Attached: authentication.tar.gz

First Look

In the provided archive, there are 100 stripped 64-bit ELF binaries.

1
2
3
4
5
6
7
8
a@a:/grey/authentication$ ls -1
03e73f2bd4c480b209caa321eb3e2eb974b680e2ec7cbada8cfd457375eaa3c0
04480e6a6ad345bf50f4f428f802656264d4738a000270d8967734cecd0ba2a9
08d4a97677fd1c8d14b069b9922518e5c7a680b67417990d998c41357d4c7c14
...
...
a@a:/grey/authentication$ ls -1 | wc -l
100
1
2
a@a:/grey/authentication$ file 03e73f2bd4c480b209caa321eb3e2eb974b680e2ec7cbada8cfd457375eaa3c0
03e73f2bd4c480b209caa321eb3e2eb974b680e2ec7cbada8cfd457375eaa3c0: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3f33add36e8e79282d0d0ed3aaa826744ddc5a39, for GNU/Linux 3.2.0, stripped

If we open a few of them in IDA Pro, we’ll see that all of these binaries basically do the same thing: fgets is used to read an input string from stdin and then each character is checked against 2 hardcoded strings using some arithmetic operations. What differs across the binaries, though, are the input length, the 2 hardcoded strings, the constant used for the arithmetic operations, and the addresses of the main function and some locations.

Below is the pseudocode for the 03e73f2bd4c480b209caa321eb3e2eb974b680e2ec7cbada8cfd457375eaa3c0 binary with variables and function names renamed by me:

 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
_BOOL8 __fastcall main(int a1, char **a2, char **a3)
{
  *input = 0LL;
  fgets(input, 30, stdin);
  return check_input(input) == 0;
}

__int64 __fastcall check_input(__int64 input)
{
  qmemcpy(str1, "Li?ittSAXlsKEi9d2GQNP3uCOTHDD", 29);
  qmemcpy(str2, "Yp9ASTqGSZ`psG3IU7jk3TdeFvwpm", 29);
  is_success = 1;
  for ( i = 0; i <= 28; ++i )
  {
    if ( check_chr(*(i + input), i) != *(str1 + i) || check_chr(*(i + input), *(str1 + i)) != *(str2 + i) )
      is_success = 0;
  }
  return is_success;
}

__int64 __fastcall check_chr(char a1, int a2)
{
  if ( a1 <= '\x1F' || a1 == '\x7F' )
    exit(1);
  return ((a2 * a1 % 1431 + a1 + a2) % 73 + 48);
}

If we connect to the server and port provided in the challenge description, we’ll see that we’re supposed to provide the correct input strings (passwords) for specified binaries (challenge codes) within 10 minutes.

1
2
3
4
5
Unauthorized access detected.
Decontamination in 10 minutes
Do you wish to override? (y/n)
y
Password required for challenge code: ffbb5c9d6d32299cc53c90d410282b42f7431c60ee40540f19603f0b4c1cfc90

Technically there’s enough time to do this manually since the server turned out to only request input strings for 5 binaries, but I didn’t know this and went ahead to learn and use angr for automated reverse engineering which I suppose is what’s hinted by the challenge name. It went surprisingly well for my first time!

Using angr

We’ll be using angr’s SimulationManager to help us find a program state with the correct input string by means of symbolic execution, so there’s a few things we need to identify in each binary we process:

  • Addresses we want to find/reach
  • Addresses we want to avoid
  • Input length
Addresses

The is_success = 0; basic block in check_input with address 0x401305 is what we’ll tell angr to avoid.

Basic block in 'check_input' that indicates our input is wrong

And in main we want check_input(input) == 0; to fail meaning we don’t want the jz after check_input to be taken, so the location we should tell angr to find for is 0x401395.

Basic block in 'main' that indicates our input is correct

But of course as mentioned earlier, I noticed these address locations and the address of main change across the binaries provided, so I first located fgets using the ELF binary’s Procedure Linkage Table (PLT) and then retrieved the first and only callgraph predecessor of fgets which is main.

To get to the basic block in check_input that we want angr to avoid, I retrieved the second last call site in main which is call check_input. I then retrieved the return site of the last function call in check_input which is a call check_chr, and added the offset of 0xF to reach the mov [rbp+is_success], 0 that we want to avoid.

To get to the basic block in main that we want angr to find, I sorted the basic blocks in main by address and took the address of the 5th last basic block.

Input

I retrieved the first basic block in main and searched through the instructions for mov esi, xx where xx is the length argument given to fgets to retrieve user input. xx - 1 will then represent the actual input length because fgets will also take in a newline character.

I also constrained the input to only be printable characters because the code in check_chr appears to try to check for that.

Solution Script

  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
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
import angr
import claripy
import capstone
import logging
from pwn import *

logging.getLogger("claripy.ast.bv").setLevel("ERROR")
logging.getLogger("angr.storage").setLevel("ERROR")
logging.getLogger("angr.simos").setLevel("ERROR")
logging.getLogger("angr.analyses.cfg").setLevel("ERROR")


def char(state, c):
    return state.solver.And(c <= "~", c >= " ")


def solve_file(file):
    print(file)

    p = angr.Project("authentication/{}".format(file), auto_load_libs=False)
    fgets_addr = p.loader.main_object.plt["fgets"]
    cfg = p.analyses.CFGFast()

    # find the basic block to FIND
    fgets_callers = cfg.functions.callgraph.predecessors(fgets_addr)
    # get the first and only calling predecessor which is main
    main_func = [cfg.functions[caller] for caller in fgets_callers][0]
    print("main: {}".format(hex(main_func.addr)))
    main_blocks = sorted(main_func.blocks, key=lambda b: b.addr)
    # we want to reach the 5th last block where the jz is not taken
    FIND = main_blocks[-5].addr
    print("FIND: {}".format(hex(FIND)))

    # find length of input required via
    # mov     esi, 1Eh
    first_block = main_blocks[0]
    for insn in first_block.capstone.insns:
        if (
            len(insn.operands) == 2
            and insn.mnemonic == "mov"
            and insn.operands[0].reg == capstone.x86.X86_REG_ESI
            and insn.operands[1].type == capstone.x86.X86_OP_IMM
        ):
            INPUT_LENGTH = insn.operands[1].imm - 1
    print("input length: {}".format(INPUT_LENGTH))

    # find the basic block to AVOID
    functions = cfg.kb.functions[main_func.addr]
    main_calls = functions.get_call_sites()
    # second last call in main is check_input
    check_input_addr = [functions.get_call_target(call) for call in main_calls][-2]

    functions = cfg.kb.functions[check_input_addr]
    check_input_calls = functions.get_call_sites()
    # get the last return site of the function calls in check_input
    AVOID = max(
        filter(
            None,
            [functions.get_call_return(call) for call in check_input_calls],
        )
    )
    OFFSET = 0xF
    AVOID += OFFSET
    print("AVOID: {}".format(hex(AVOID)))

    flag = claripy.BVS("flag", INPUT_LENGTH * 8)

    state = p.factory.entry_state(stdin=flag)
    state.options.add(angr.options.LAZY_SOLVES)

    for c in flag.chop(8):
        state.solver.add(char(state, c))

    sm = p.factory.simulation_manager(state)
    sm.explore(find=FIND, avoid=AVOID)
    print("finding...")

    if sm.found:
        found = sm.found[0]
        solution = found.solver.eval(flag, cast_to=bytes)
        print("found: {}".format(solution))
    else:
        print("not found")
        solution = None
    print("-" * 64)
    return solution


if __name__ == "__main__":
    conn = remote("challs.nusgreyhats.org", 10523)
    conn.recvuntil(b")")
    conn.recvline()
    conn.sendline(b"y")
    response = b""
    while True:
        response = conn.recvline()
        if b": " in response:
            challenge_code = response.split(b": ")[-1].rstrip(b"\n").decode()
            password = solve_file(challenge_code)
            conn.sendline(password)
        else:
            break
    print(response)
 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
[x] Opening connection to challs.nusgreyhats.org on port 10523
INFO    | 2022-06-11 03:22:06,986 | pwnlib.tubes.remote.remote.2891224537696 | Opening connection to challs.nusgreyhats.org on port 10523
[x] Opening connection to challs.nusgreyhats.org on port 10523: Trying 35.247.145.55
INFO    | 2022-06-11 03:22:06,992 | pwnlib.tubes.remote.remote.2891224537696 | Opening connection to challs.nusgreyhats.org on port 10523: Trying 35.247.145.55
[+] Opening connection to challs.nusgreyhats.org on port 10523: Done
INFO    | 2022-06-11 03:22:07,013 | pwnlib.tubes.remote.remote.2891224537696 | Opening connection to challs.nusgreyhats.org on port 10523: Done
a9f59335ea82cf55f4f7f7dd5d8eb3f8ded783bd2d902adcc25a4aca75328895
main: 0x40131f
FIND: 0x401380
input length: 25
AVOID: 0x4012f7
finding...
found: b'08xsiic4i626rqn2urg5yhx0x'
----------------------------------------------------------------
65636c7430d22186ac375d173de25ea6305d5aa7b0feb4526e1ec1dd2bcc16cd
main: 0x40132b
FIND: 0x40138d
input length: 27
AVOID: 0x401303
finding...
found: b'vccbxfmn6cr1mvfe2f902sgzdpf'
----------------------------------------------------------------
70b4bed62eb3e1c2cbbd9e393154a7b5a403e973400c662df032d4f59fe8a333
main: 0x40132b
FIND: 0x40138d
input length: 27
AVOID: 0x401303
finding...
found: b'brrb92dt6qeolkxdalihzid6kte'
----------------------------------------------------------------
5e346c4ce1773d5e4a5bb498dd3c9d5124d03ecfc494e15ee5ac6b643be7ee9d
main: 0x40132f
FIND: 0x401397
input length: 29
AVOID: 0x401307
finding...
found: b'zxokdvjcwbdo0bq55xpoq7qv23df5'
----------------------------------------------------------------
d98f704409467ff7d85c439371b0f1acf1e5850b6e030effe4c9d2a9af83c825
main: 0x401331
FIND: 0x40139d
input length: 30
AVOID: 0x401309
finding...
found: b'wtg60dw2ew3tgn112c0nybfbr29o9|'
----------------------------------------------------------------
b'grey{A11_H4il_SkyN3t}\n'
[*] Closed connection to challs.nusgreyhats.org port 10523
INFO    | 2022-06-11 03:23:39,041 | pwnlib.tubes.remote.remote.2891224537696 | Closed connection to challs.nusgreyhats.org port 10523

Flag: grey{A11_H4il_SkyN3t}

🩸 Runtime Environment 1

GO and try to solve this basic challenge.

FAQ: If you found the input leading to the challenge.txt you are on the right track

MD5 (gogogo.tar.gz) = 5515f1c3eee00e4042bf7aba84bbec5c

  • rootkid

Attached: gogogo.tar.gz

1
2
3
4
a@a:/grey/gogogo$ file binary
binary: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=OHBJFJh5S4MEkda8Q683/cMydJq6y9QbVjBCjK1KP/8R1f9ddSl9EfpM8KP2Dy/3G9-Ju3BW7WUsgoGNyvl, not stripped
a@a:/grey/gogogo$ cat challenge.txt
GvVf+fHWz1tlOkHXUk3kz3bqh4UcFFwgDJmUDWxdDTTGzklgIJ+fXfHUh739+BUEbrmMzGoQOyDIFIz4GvTw+j--

In the given archive there’s a 64-bit ELF Go binary and a challenge.txt containing encoded text that looks like some variant of Base64 encoding.
From blackbox analysis, the encoding scheme also seems similar to Base64 in that 3-byte input blocks (aaa) are independently encoded to 4-byte output blocks (CGTx), and padding (-) is used to keep the output length a multiple of 4 when the input length is not a multiple of 3:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
a@a:/grey/gogogo$ echo a | ./binary
CV--
a@a:/grey/gogogo$ echo aa | ./binary
CGJ-
a@a:/grey/gogogo$ echo aaa | ./binary
CGTx
a@a:/grey/gogogo$ echo aaaa | ./binary
CGTxCV--
a@a:/grey/gogogo$ echo aaaaa | ./binary
CGTxCGJ-
a@a:/grey/gogogo$ echo aaaaaa | ./binary
CGTxCGTx

Let’s check out the pseudocode of the binary in IDA Pro for further analysis.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// main.main
void __cdecl main_main()
{
  v10 = runtime_newobject(&RTYPE_string);
  v12[0] = &RTYPE__ptr_string;
  v12[1] = v10;
  fmt_Fscanln(&go_itab__ptr_os_File_comma_io_Reader, os_Stdin, v12, 1LL, 1LL);
  v7 = 4 * ((((((v10[1] + 2) * 0xAAAAAAAAAAAAAAABLL) >> 64) + v10[1] + 2) >> 1) - ((v10[1] + 2) >> 63));
  v9 = runtime_makeslice(&RTYPE_uint8, v7, v7);
  v1 = runtime_stringtoslicebyte(v8, *v10, v10[1]);
  main_Encode(v9, v7, v7, v1, v2);
  v3 = runtime_slicebytetostring(0LL, v4, v5, v6);
  v0 = runtime_convTstring(v3, *(&v3 + 1));
  v11[0] = &RTYPE_string;
  v11[1] = v0;
  fmt_Fprintln(&go_itab__ptr_os_File_comma_io_Writer, os_Stdout, v11, 1LL, 1LL);
}

A string is read from stdin, converted into a Go slice of bytes, and then passed into main_Encode. The encoded slice of bytes is then converted back into a string and printed.

What does main_Encode do? At the top, we see a string b64str containing 64 unique characters. This is used to form the majority of output in the first while loop where input_ptr is iterated over in chunks of 3 while output is iterated over in chunks of 4.

 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
82
83
84
85
86
// main.Encode
__int64 __usercall main_Encode@<rax>(
        __int64 output,
        unsigned __int64 output_length,
        __int64 a3,
        __int64 input_ptr,
        unsigned __int64 input_length)
{
  qmemcpy(b64str, "NaRvJT1B/m6AOXL9VDFIbUGkC+sSnzh5jxQ273d4lHPg0wcEpYqruWyfZoM8itKe", sizeof(b64str));
  i_by_3 = 0LL;
  j_enc_by_4 = 0LL;
  while ( i_by_3 < 3 * (input_length / 3) )
  {
    if ( i_by_3 >= input_length )
      runtime_panicIndex();
    if ( i_by_3 + 1 >= input_length )
      runtime_panicIndex();
    if ( i_by_3 + 2 >= input_length )
      runtime_panicIndex();
    v7 = (*(input_ptr + i_by_3) << 16) | (*(input_ptr + i_by_3 + 1) << 8) | *(i_by_3 + input_ptr + 2);
    if ( j_enc_by_4 >= output_length )
      runtime_panicIndex();
    *(output + j_enc_by_4) = *(b64str + ((v7 >> 18) & 0x3F));
    v8 = v7;
    v9 = *(b64str + ((v7 >> 12) & 0x3F));
    if ( j_enc_by_4 + 1 >= output_length )
      runtime_panicIndex();
    *(j_enc_by_4 + output + 1) = v9;
    v10 = v8;
    v11 = *(b64str + ((v8 >> 6) & 0x3F));
    if ( j_enc_by_4 + 2 >= output_length )
      runtime_panicIndex();
    *(j_enc_by_4 + output + 2) = v11;
    v12 = *(b64str + (v10 & 0x3F));
    if ( j_enc_by_4 + 3 >= output_length )
      runtime_panicIndex();
    *(output + j_enc_by_4 + 3) = v12;
    i_by_3 += 3LL;
    j_enc_by_4 += 4LL;
  }
  remainder = input_length - i_by_3;
  if ( input_length == i_by_3 )
    return a3;
  if ( i_by_3 >= input_length )
    runtime_panicIndex();
  if ( remainder == 2 )
  {
    if ( i_by_3 + 1 >= input_length )
      runtime_panicIndex();
    v14 = (*(input_ptr + i_by_3) << 16) | (*(input_ptr + i_by_3 + 1) << 8);
  }
  else
  {
    v14 = *(input_ptr + i_by_3) << 16;
  }
  v15 = v14;
  v16 = *(b64str + ((v14 >> 18) & 0x3F));
  if ( j_enc_by_4 >= output_length )
    runtime_panicIndex();
  *(output + j_enc_by_4) = v16;
  v17 = v15;
  v18 = *(b64str + ((v15 >> 12) & 0x3F));
  if ( j_enc_by_4 + 1 >= output_length )
    runtime_panicIndex();
  *(j_enc_by_4 + output + 1) = v18;
  if ( remainder == 1 )                         // add padding
  {
    if ( j_enc_by_4 + 2 >= output_length )
      runtime_panicIndex();
    *(j_enc_by_4 + output + 2) = '-';
    if ( j_enc_by_4 + 3 >= output_length )
      runtime_panicIndex();
    *(j_enc_by_4 + output + 3) = '-';
  }
  else if ( remainder == 2 )
  {
    v20 = *(b64str + ((v17 >> 6) & 0x3F));
    if ( j_enc_by_4 + 2 >= output_length )
      runtime_panicIndex();
    *(j_enc_by_4 + output + 2) = v20;
    if ( j_enc_by_4 + 3 >= output_length )
      runtime_panicIndex();
    *(j_enc_by_4 + output + 3) = '-';           // add padding
  }
  return a3;
}

At the bottom, we see that for remaining input bytes not covered by the chunks of 3, the character - is padded to output. All these observations match what we found from our blackbox analysis and it really does seem like Base64 encoding. For additional confirmation, I also Googled the >> 18 & 0x3f seen in the code and the results all show code for Base64 encoding.
The difference here is that NaRvJT1B/m6AOXL9VDFIbUGkC+sSnzh5jxQ273d4lHPg0wcEpYqruWyfZoM8itKe is used as a custom character set with - as the padding instead of the standard ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ with = as the padding.
At this point I just tried decoding the contents of challenge.txt in CyberChef with the custom Base64 character set and it turns out 4 rounds of decoding are needed.

Flag: grey{B4s3d_G0Ph3r_r333333}

🩸 Runtime Environment 2

This time it HAS to be harder.

MD5 (hasbeen.tar.gz) = 46ff7d24975679901d8f8d769e567b09

  • rootkid

Attached: hasbeen.tar.gz

1
2
3
4
5
a@a:/grey/hasbeen$ file binary
binary: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=edaa0989ef693326c9c1cb23f37ce489d54ce939, not stripped
a@a:/grey/hasbeen$ xxd challenge.bin
00000000: 7cab e724 16c3 6067 1391 49c0 4e20 c4a2  |..$..`g..I.N ..
00000010: 48d0 318c 704a 0ce2 c57c 98af 0398 6577  H.1.pJ...|....ew

A 64-bit ELF binary is given as well as a challenge.bin containing some encoded/encrypted data.

In IDA Pro we see that the ELF’s main references functions hs_main and ZCMain_main_closure which is a sign that this is a GHC-compiled Haskell binary.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  HIDWORD(v5) = HIDWORD(defaultRtsConfig);
  LODWORD(v5) = 3;
  hs_main(
    argc,
    (_DWORD)argv,
    (unsigned int)&ZCMain_main_closure,
    (_DWORD)argv,
    v3,
    v4,
    v5,
    1LL,
    0LL,
    1LL,
    0LL,
    (__int64)&FileEventLogWriter,
    (__int64)FlagDefaultsHook,
    (__int64)OnExitHook,
    (__int64)StackOverflowHook,
    (__int64)OutOfHeapHook,
    (__int64)MallocFailHook,
    0LL);
}

The decompiler available at https://github.com/gereeter/hsdecomp produces incomplete output with errors so I looked for forks and found this one https://github.com/Timeroot/hsdecomp/ to give better output:

 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
Main_main_closure = >>= $fMonadIO
    (fmap $fFunctorIO unpack getLine)
    (\loc_4232104_arg_0 ->
        >>= $fMonadIO
            getCurrentTime
            (\loc_4231968_arg_0 ->
                >>= $fMonadIO
                    ($
                        (\loc_4231776_arg_0 loc_4231776_arg_1 loc_4231776_arg_2 loc_4231776_arg_3 loc_4231776_arg_4 -> return $fMonadIO)
                        ($
                            (map (\loc_4231432_arg_0 loc_4231432_arg_1 loc_4231432_arg_2 loc_4231432_arg_3 loc_4231432_arg_4 -> . (\loc_4231360_arg_0 loc_4231360_arg_1 loc_4231360_arg_2 loc_4231360_arg_3 loc_4231360_arg_4 -> fromIntegral $fIntegralInt32 $fNumInteger) (\loc_4231256_arg_0 -> .&. $fBitsInt32 loc_4231256_arg_0 (\loc_4231128_arg_0 loc_4231128_arg_1 loc_4231128_arg_2 loc_4231128_arg_3 loc_4231128_arg_4 -> fromInteger $fNumInt32 (IS 255)))))
                            ($
                                (\loc_4230968_arg_0 -> loc_4229392 $fIntegralInt loc_4230968_arg_0)
                                (.
                                    (case $fRealFracFixed $fHasResolutionTYPEE12 of
                                        loc_4228312_case_tag_DEFAULT_arg_0@_DEFAULT -> floor <index 0 in loc_4228312_case_tag_DEFAULT> $fIntegralInt
                                    )
                                    (. nominalDiffTimeToSeconds utcTimeToPOSIXSeconds)
                                    loc_4231968_arg_0
                                )
                            )
                        )
                    )
                    (\loc_4230768_arg_0 -> >>= $fMonadIO ($ (\loc_4230568_arg_0 loc_4230568_arg_1 loc_4230568_arg_2 loc_4230568_arg_3 loc_4230568_arg_4 -> return $fMonadIO) (zipWith (\loc_4230360_arg_0 loc_4230360_arg_1 loc_4230360_arg_2 loc_4230360_arg_3 loc_4230360_arg_4 -> xor $fBitsInt32) (map (\loc_4230184_arg_0 loc_4230184_arg_1 loc_4230184_arg_2 loc_4230184_arg_3 loc_4230184_arg_4 -> fromIntegral $fIntegralWord8 $fNumInt32) loc_4232104_arg_0) (map (fromIntegral $fIntegralInteger $fNumInt32) loc_4230768_arg_0))) (\loc_4229920_arg_0 -> $ putStr ($ pack (map (\loc_4229592_arg_0 loc_4229592_arg_1 loc_4229592_arg_2 loc_4229592_arg_3 loc_4229592_arg_4 -> . chr (fromIntegral $fIntegralInt32 $fNumInt)) loc_4229920_arg_0))))
            )
    )

loc_4229392 = \loc_4229392_arg_0 loc_4229392_arg_1 -> : loc_4229128 (loc_4229392 $fIntegralInt32 loc_4229128)
loc_4229128 = xor $fBitsInt32 loc_4228928 (shiftL $fBitsInt32 loc_4228928 (I# 5))
loc_4228928 = xor $fBitsInt32 loc_4228728 (shiftR $fBitsInt32 loc_4228728 (I# 17))
loc_4228728 = xor $fBitsInt32 loc_4228568 (shiftL $fBitsInt32 loc_4228568 (I# 13))
loc_4228568 = fromIntegral loc_4229392_arg_0 $fNumInt32 loc_4229392_arg_1

The drawback though is that 5 unused arguments are added to some of the anonymous functions thus cluttering up the output. In the cleaned-up output with renamed variables below, I’ve just renamed these arguments to {5argsthing} so that it’s less of an eyesore.

 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
Main_main_closure = >>= $fMonadIO
    (fmap $fFunctorIO unpack getLine)
    (\input ->
        >>= $fMonadIO
            getCurrentTime
            (\currentTimeUTC ->
                >>= $fMonadIO
                    ($
                        (\{5argsthing} -> return $fMonadIO)
                        ($
                            {- for each of the random numbers in the generate list, mask it with & 0xff -}
                            (map 
                                (
                                    \{5argsthing} -> 
                                    . 
                                    (\{5argsthing} -> fromIntegral $fIntegralInt32 $fNumInteger) 
                                    (
                                        \loc_4231256_arg_0 -> .&. $fBitsInt32 loc_4231256_arg_0 
                                        (
                                            \{5argsthing} -> fromInteger $fNumInt32 (IS 255)
                                        )
                                    )
                                )
                            )
                            {- generate list of random numbers based on current floored time in seconds -}
                            ($
                                (\flooredtime_4230968 -> rng_4229392 $fIntegralInt flooredtime_4230968)
                                {- floor . nominalDiffTimeToSeconds . utcTimeToPOSIXSeconds -}
                                (.  
                                    (case $fRealFracFixed $fHasResolutionTYPEE12 of
                                        loc_4228312_case_tag_DEFAULT_arg_0@_DEFAULT -> floor <index 0 in loc_4228312_case_tag_DEFAULT> $fIntegralInt
                                    )
                                    (. nominalDiffTimeToSeconds utcTimeToPOSIXSeconds)currentTimeUTC
                                )
                            )
                        )
                    )
                    (
                        \randnumbers_4230768 -> 
                            >>= $fMonadIO 
                            ($ 
                                (\{5argsthing} -> return $fMonadIO) 
                                (
                                    zipWith (\{5argsthing} -> xor $fBitsInt32) 
                                    (map (\{5argsthing}  -> fromIntegral $fIntegralWord8 $fNumInt32) input) 
                                    (map (fromIntegral $fIntegralInteger $fNumInt32) randnumbers_4230768)
                                )                  
                            ) 
                            (
                                \output_4229920 -> $ putStr 
                                ( $ pack 
                                    ( 
                                        map ( \{5argsthing} -> . chr (fromIntegral $fIntegralInt32 $fNumInt) ) 
                                        output_4229920
                                    )
                                )
                            )
                    )
            )
    )


{- i think this recursively creates a list of random numbers and returns it -}
rng_4229392 = \IntegralIntarg timearg -> : a4_4229128 (rng_4229392 $fIntegralInt32 a4_4229128)
a4_4229128 = xor $fBitsInt32 a3_4228928 (shiftL $fBitsInt32 a3_4228928 (I# 5))
a3_4228928 = xor $fBitsInt32 a2_4228728 (shiftR $fBitsInt32 a2_4228728 (I# 17))
a2_4228728 = xor $fBitsInt32 a1_4228568 (shiftL $fBitsInt32 a1_4228568 (I# 13))
a1_4228568 = fromIntegral IntegralIntarg $fNumInt32 timearg

{-
    xorshift32?:
    a1 = from timearg
    a2 = a1 ^ (a1 << 13)
    a3 = a2 ^ (a2 >> 17)
    a4 = a3 ^ (a3 << 5)
    return a4?
-}

Now to be frank I can’t explain in-depth how the Haskell code flow exactly works, but this is my general interpretation:

  1. Input string is taken from stdin
  2. Current UTC time is retrieved, converted to POSIX time, and floored to seconds
  3. POSIX time in seconds is used to seed a Xorshift32 generator function
  4. Each byte in the input string is XOR’d with a 0xFF-masked integer generated from the Xorshift32 function
  5. Final output is converted to characters, packed together and printed

The possible UTC time used to produce the contents in challenge.bin could range from the time binary was made to the time challenge.bin was made. I replicated everything in Python and bruteforced for the time used although it turns out to be just the exact last modified time of challenge.bin.

 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
import binascii


# # alternative from https://stackoverflow.com/questions/1375897/how-to-get-the-signed-integer-value-of-a-long-in-python
# def toSigned32(n):
#     n = n & 0xffffffff
#     return (n ^ 0x80000000) - 0x80000000


def toSigned(n, bits):
    n = n & (2**bits - 1)
    return n | (-(n & (1 << (bits - 1))))


if __name__ == "__main__":
    challenge_bin = binascii.unhexlify(
        "7cabe72416c36067139149c04e20c4a248d0318c704a0ce2c57c98af03986577"
    )
    max_time = 1654353866  # last modified time of challenge.bin: Saturday, June 4, 2022 10:44:26 PM GMT+08:00
    min_time = 1654352191  # last modified time of binary: Saturday, June 4, 2022 10:16:31 PM GMT+08:00

    for time in range(max_time, min_time, -1):
        # reset seed state
        seed = time

        def xorshift32():
            global seed
            seed ^= toSigned(seed << 13, 32)
            seed ^= toSigned(seed >> 17, 32)
            seed ^= toSigned(seed << 5, 32)
            return seed

        output = ""
        for c in challenge_bin:
            o = c ^ (xorshift32() & 0xFF)
            output += chr(o)

        # print(time, output)
        if output[:5] == "grey{":
            print("found: {} {}".format(time, output))
1
2
C:\grey\hasbeen>py re2.py
found: 1654353866 grey{Funct1on41_P4rad1s3_iZ_Fun}

Flag: grey{Funct1on41_P4rad1s3_iZ_Fun}

🍭 flappy-js

Are you a pro gamer? Can you get a score of 31337 in this game?

(flag format is greyctf{…} because I made a mistake :p)

  • daniellimws

hxxp://challs.nusgreyhats.org:10529/

Hint: Use your browser’s dev tools.

The website lets us play a game called “Clumsy Bird” which is just a Flappy Bird clone. In the HTML we can see two scripts being loaded.

1
2
3
4
5
6
7
8
<!-- melonJS Library -->
<script type="text/javascript" src="js/melonJS-min.js" ></script>
<script type="text/javascript" src="build/clumsy-min.js" ></script>
<script type="text/javascript">
    window.onReady(function onReady() {
        game.onload();
    });
</script>

melonJS is just a library so I searched for “flag” in clumsy-min.js which is helpfully pretty-printed by the Firefox Developer Tools.

Searching for 'flag' in clumsy-min.js in Firefox Developer Tools

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
draw: function (a) {
    game.data.start
    && me.state.isCurrent(me.state.PLAY)
    && (
        this.stepsFont.draw(a, game.data.steps, me.game.viewport.width / 2, 10),
        game.data.steps >= 31337
        && (
            0 == this.flag.length && (this.flag = genFlag()),
            this.flagFont = new me.Font('roboto', 35, '#000', 'center'),
            this.flagFont.draw(a, 'greyctf{' + this.flag + '}', me.game.viewport.width / 2, 110)
        )
    )
}

From the Javascript snippet above where “flag” is found, it seems that we just need to call genFlag() so that’s what I did right in my Firefox Browser Console and out came the flag “5uch_4_pr0_g4m3r”.

Flag: greyctf{5uch_4_pr0_g4m3r}

flappy-o

I know you cheated in flappy-js. This time the game is written in C, I don’t think you can cheat so easily. Or can you?

Show me your skills by getting a score of at least 64.

MD5 (flappybird) = f1f36482358dc35992f076e6ea483df8

  • daniellimws

Hint: Consider patching

Attached: flappybird

This one is solved together with 🩸 flappy-o2 below.

Flag: grey{y0u_4r3_pr0_4t_7h1s_g4m3_b6d8745a1cc8d51effb86690bf4b27c9}

🩸 flappy-o2

This challenge uses the same binary as flappy-o.

Every 10000 points you reach, you unlock a character of the bonus flag. This is the real test of your skills.

MD5 (flappybird) = f1f36482358dc35992f076e6ea483df8

  • daniellimws

Attached: flappybird

1
flappybird: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=7b93a7bc7eee1ab668de0cad3ed2fb3927d0ec45, for GNU/Linux 3.2.0, with debug_info, not stripped

This time the Flappy Bird game runs in an unstripped 64-bit ELF and you’re hinted to patch it, but I found it easier to just lift out the flag decoding functions and run them separately.

In main, gameLoop() is called which calls updateAndDrawFlag().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void __cdecl updateAndDrawFlag()
{
  mvprintw(3, col / 2 - 20, "%s", flag1);
  mvprintw(4, col / 2 - 20, "%s", flag2);
  actualScore = score / 8;
  if ( score / 8 != prevScore )
  {
    if ( actualScore - prevScore != 1 )
      reportCheater();
    if ( actualScore <= 0x3F )
      flag1[actualScore - 1] = genFlag1(actualScore - 1);
    lfsr2(1);
    if ( !(actualScore % 10000) && (actualScore / 10000) <= 25 )
      *&flag2[4 * (actualScore / 10000) - 4] = genFlag2(actualScore - 10000);
    prevScore = actualScore;
  }
}

The arrays flag1 and flag2 are populated by genFlag1() and genFlag2() respectively based on your score and actualScore. Linear-feedback shift register (LFSR) functions lsfr1() and lsfr2() are used to achieve this along with hardcoded arrays key1 and key2 as well as a hardcoded lsfr2_seed.

Once we know these key components of the flag generation, we can copy the relevant pseudocode out, compile it and run it to print both flags. We just have to increment score in a loop to simulate how it is incremented by checkAndHandleCollision() in gameLoop(). More specifically, it should be incremented until 10000 * 8 * 25 because actualScore = score / 8; and the condition (actualScore / 10000) <= 25 is used to generate the 25 DWORDs of the 2nd flag.

  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
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#include <stdio.h>

unsigned char key1[] =
{
  0xAA, 0x7A, 0xE1, 0xBB, 0x9A, 0xE7, 0xFF, 0x7C, 0x35, 0x01,
  0x06, 0x09, 0xC2, 0x50, 0x62, 0x38, 0xDB, 0x76, 0xD5, 0xE1,
  0x68, 0xA9, 0xBF, 0xB4, 0x52, 0x8F, 0xC0, 0x17, 0x0E, 0x2F,
  0xDA, 0xEA, 0x8A, 0xCF, 0xA2, 0x90, 0xE7, 0x08, 0xEB, 0x0E,
  0x3B, 0x14, 0x72, 0xBE, 0x9A, 0xDE, 0xD5, 0x51, 0x97, 0x2C,
  0xBC, 0xF3, 0x35, 0xB6, 0x21, 0x29, 0x7D, 0xA8, 0xD7, 0x2B,
  0xED, 0xFE, 0xF0, 0x00
};

unsigned int key2[25] =
{
  2091533035u, 3444833704u, 4148299095u, 1382543044u, 2034135277u,
  1758572224u, 953820139u, 1864200113u, 961059707u, 4109923606u,
  3982998552u, 2329111456u, 2938792015u, 2156409201u, 2261679400u,
  1133293590u, 1468307596u, 1270163339u, 3218919622u, 1843033993u,
  2968558243u, 1433696446u, 3279073000u, 1084392560u, 2824157242u
};

unsigned int lfsr1(int n)
{
  int i;    // [rsp+8h] [rbp-Ch]
  int seed; // [rsp+Ch] [rbp-8h]
  int lsb;  // [rsp+10h] [rbp-4h]

  seed = 43981;
  for (i = 0; i < n; ++i)
  {
    lsb = seed & 1;
    seed >>= 1;
    if (lsb)
      seed ^= 0x82EEu;
  }
  return seed;
}

int lfsr2_seed = 439041101;

unsigned int lfsr2(int n)
{
  int i;   // [rsp+Ch] [rbp-8h]
  int lsb; // [rsp+10h] [rbp-4h]

  for (i = 0; i < n; ++i)
  {
    lsb = lfsr2_seed & 1;
    lfsr2_seed >>= 1;
    if (lsb)
      lfsr2_seed ^= 0x80000DD7;
  }
  return lfsr2_seed;
}

char genFlag1(int n)
{
  unsigned int v1; // bl

  v1 = key1[n];
  return v1 ^ lfsr1(n);
}

int genFlag2(int i)
{
  unsigned v1; // ax

  v1 = lfsr1(i);
  return lfsr2(v1) ^ key2[i / 10000];
}

int main()
{
  int actualscore;
  int prevscore;
  int dword;
  char flag1[68];
  int flag2[100];
  for (int score = 0; score <= 10000 * 8 * 25; score++)
  {
    actualscore = score / 8;
    if (actualscore != prevscore)
    {
      lfsr2(1);
      if (!(actualscore % 10000) && (unsigned int)(actualscore / 10000) <= 25)
      {
        dword = genFlag2(actualscore - 10000);
        int fourth = (dword >> 24) & 0xff;
        int third = (dword >> 16) & 0xff;
        int second = (dword >> 8) & 0xff;
        int first = (dword)&0xff;
        flag2[4 * (actualscore / 10000) - 1] = fourth;
        flag2[4 * (actualscore / 10000) - 2] = third;
        flag2[4 * (actualscore / 10000) - 3] = second;
        flag2[4 * (actualscore / 10000) - 4] = first;
      }
      if (actualscore <= 63)
      {
        flag1[actualscore - 1] = genFlag1(actualscore - 1);
        printf("%c", flag1[actualscore - 1]);
      }
      prevscore = actualscore;
    }
  }
  printf("\n");
  for (int i = 0; i < 100; i++)
  {
    printf("%c", flag2[i]);
  }
  printf("\n");
  return 0;
}

Output:

1
2
grey{y0u_4r3_pr0_4t_7h1s_g4m3_b6d8745a1cc8d51effb86690bf4b27c9}
grey{y0u_4r3_v3ry_g00d_4t_7h1s_g4m3_c4n_y0u_t34ch_m3_h0w_t0_b3_g00d_ef4bd282d7a2ab1ebdcc3616dbe7afb}

Flag: grey{y0u_4r3_v3ry_g00d_4t_7h1s_g4m3_c4n_y0u_t34ch_m3_h0w_t0_b3_g00d_ef4bd282d7a2ab1ebdcc3616dbe7afb}

🍭 Memory Game (Part 1)

Here’s a fun game to destress.

Do you know where the image assets are stored? I’ve made a nice drawing for you.

MD5 (memory-game.apk) = 1c491c078269748d1abf65dff6449149

  • daniellimws

Attached: memory-game.apk

We’re supposed to look for some image asset in the APK, so I used apktool to decode the APK with apktool d memory-game.apk and searched with ripgrep in the res/drawable* folders because that’s where image resources are supposed to be stored according to the Android Developer Documentation.
Turns out that there’s a flag.png in res/drawable-hdpi/ with the flag text in the image.

1
2
a@a:/apktool/memory-game/res$ rg --files res/drawable* | rg flag
res/drawable-hdpi/flag.png

Flag: grey{th1s_1s_dr4w4bl3_bu7_e4s13r_t0_7yp3}

🩸 Memory Game (Part 2)

Can you finish MASTER difficulty in 20 seconds? If you can, the flag will be given to you through logcat with the tag FLAG.

MD5 (memory-game.apk) = 1c491c078269748d1abf65dff6449149

  • daniellimws

Hint: Frida is nice.

Attached: memory-game.apk

I opened the APK in JADX and did a text search for “flag” and found results in com.snatik.matches.engine.Engine.onEvent(FlipCardEvent). This should be what we’re looking for as the code Log.i("FLAG", ...) matches the challenge’s description of the flag being printed “through logcat with the tag FLAG”.

Text search for 'flag' in JADX

Now that we know which part of the code to look at, I switched to Bytecode Viewer to decompile the APK because I prefer the built-in Procyon Decompiler’s output.
Below is the relevant segment of the code in com.snatik.matches.engine.Engine.onEvent(FlipCardEvent) where the flag is logged if MASTER difficulty was chosen and the game was finished within the 20 seconds time limit:

 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
if (this.mPlayingGame.boardConfiguration.difficulty == 6 && passedSeconds < time) {
    final Key key = null;
    try {
        instance = (FlipCardEvent)SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
        try {
            final Cipher instance2 = Cipher.getInstance("AES/CBC/PKCS5Padding");
        }
        catch (final NoSuchPaddingException ex) {}
        catch (final NoSuchAlgorithmException ex) {}
    }
    catch (final NoSuchPaddingException ex) {}
    catch (final NoSuchAlgorithmException ex2) {}
    instance = null;
    final NoSuchPaddingException ex;
    ex.printStackTrace();
    final Cipher instance2 = null;
    Rnd.reSeed();
    final byte[] array = new byte[16];
    for (int i = 0; i < 16; ++i) {
        array[i] = (byte)Rnd.get(256);
    }
    final PBEKeySpec pbeKeySpec = new PBEKeySpec("1.01.001007".toCharArray(), array, 65536, 256);

    Key key2;
    try {
        key2 = new SecretKeySpec(((SecretKeyFactory)instance).generateSecret(pbeKeySpec).getEncoded(), "AES");
    }
    catch (final InvalidKeySpecException ex3) {
        ex3.printStackTrace();
        key2 = key;
    }
    final byte[] array2 = new byte[16];
    for (int j = 0; j < 16; ++j) {
        array2[j] = (byte)Rnd.get(256);
    }
    final IvParameterSpec ivParameterSpec = new IvParameterSpec(array2);
    try {
        instance2.init(2, key2, ivParameterSpec);
    }
    catch (final InvalidKeyException ex4) {
        ex4.printStackTrace();
    }
    catch (final InvalidAlgorithmParameterException ex5) {
        ex5.printStackTrace();
    }
    try {
        Log.i("FLAG", new String(instance2.doFinal(Base64.decode("diDrBf4+uZMtDV+0k/3BCGM4xyTpEyGEuUFYegIaSjQyQcgfIfZRbvGQ9hHMqnuflNCKv4HW/NXq93j4QqLc/Q==", 0)), StandardCharsets.UTF_8));
    }
    catch (final IllegalBlockSizeException ex6) {
        ex6.printStackTrace();
    }
    catch (final BadPaddingException ex7) {
        ex7.printStackTrace();
    }
}

I was too lazy to set up emulators and Android Debug Bridge (adb) so I decided to just copy and port everything above over to Java including com.snatik.matches.rng.MTRandom.class and com.snatik.matches.rng.Rnd.class in order to print out the flag.

  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
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
import javax.crypto.spec.*;
import java.security.spec.*;
import javax.crypto.*;
import java.security.*;
import java.util.*;
import java.nio.charset.*;
import java.io.*;

class Main {
    public static void main(String args[]) {
        SecretKeyFactory instance = null;
        Cipher instance2 = null;
        try {
            instance = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
            instance2 = Cipher.getInstance("AES/CBC/PKCS5Padding");  
        }
        catch (final NoSuchAlgorithmException ex9) {}
        catch (final NoSuchPaddingException ex10) {}
        
        Rnd.reSeed();
        final byte[] array = new byte[16];
        for (int i = 0; i < 16; ++i) {
            array[i] = (byte)Rnd.get(256);
        }
        
        final PBEKeySpec pbeKeySpec = new PBEKeySpec("1.01.001007".toCharArray(), array, 65536, 256);
        Key key2 = null;
        try {
            key2 = new SecretKeySpec(((SecretKeyFactory)instance).generateSecret(pbeKeySpec).getEncoded(), "AES");
        }
        catch (final InvalidKeySpecException ex3) {}
        
        final byte[] array2 = new byte[16];
        for (int j = 0; j < 16; ++j) {
            array2[j] = (byte)Rnd.get(256);
        }
        
        final IvParameterSpec ivParameterSpec = new IvParameterSpec(array2);
        try {
             instance2.init(2, key2, ivParameterSpec);
        }
        catch (final InvalidKeyException ex4) {}
        catch (final InvalidAlgorithmParameterException ex5) {}
        
        try {
            System.out.println(new String(instance2.doFinal(Base64.getDecoder().decode(new String("diDrBf4+uZMtDV+0k/3BCGM4xyTpEyGEuUFYegIaSjQyQcgfIfZRbvGQ9hHMqnuflNCKv4HW/NXq93j4QqLc/Q==").getBytes("UTF-8"))), StandardCharsets.UTF_8));
        } 
        catch (final IllegalBlockSizeException ex6) {}
        catch (final BadPaddingException ex7) {}
        catch (final UnsupportedEncodingException ex8) {}
    }
}

class Rnd
{
    private static MTRandom rnd;
    
    static {
        Rnd.rnd = new MTRandom((long)(-1966625086));
    }
    
    public Rnd() {
        super();
    }
    
    public static float get() {
        return Rnd.rnd.nextFloat();
    }
    
    public static int get(final int n) {
        final double nextDouble = Rnd.rnd.nextDouble();
        final double n2 = n;
        Double.isNaN(n2);
        return (int)Math.floor(nextDouble * n2);
    }
    
    public static int get(final int n, final int n2) {
        final double nextDouble = Rnd.rnd.nextDouble();
        final double n3 = n2 - n + 1;
        Double.isNaN(n3);
        return n + (int)Math.floor(nextDouble * n3);
    }
    
    public static boolean nextBoolean() {
        return Rnd.rnd.nextBoolean();
    }
    
    public static double nextDouble() {
        return Rnd.rnd.nextDouble();
    }
    
    public static double nextGaussian() {
        return Rnd.rnd.nextGaussian();
    }
    
    public static int nextInt() {
        return Rnd.rnd.nextInt();
    }
    
    public static int nextInt(final int n) {
        final double nextDouble = Rnd.rnd.nextDouble();
        final double n2 = n;
        Double.isNaN(n2);
        return (int)Math.floor(nextDouble * n2);
    }
    
    public static void reSeed() {
        Rnd.rnd = new MTRandom((long)(-1966625086));
    }
}


class MTRandom extends Random
{
    private static final long DEFAULT_SEED = 5489L;
    private static final int LOWER_MASK = Integer.MAX_VALUE;
    private static final int M = 397;
    private static final int[] MAGIC;
    private static final int MAGIC_FACTOR1 = 1812433253;
    private static final int MAGIC_FACTOR2 = 1664525;
    private static final int MAGIC_FACTOR3 = 1566083941;
    private static final int MAGIC_MASK1 = -1658038656;
    private static final int MAGIC_MASK2 = -272236544;
    private static final int MAGIC_SEED = 19650218;
    private static final int N = 624;
    private static final int UPPER_MASK = Integer.MIN_VALUE;
    private static final long serialVersionUID = -515082678588212038L;
    private transient boolean compat;
    private transient int[] ibuf;
    private transient int[] mt;
    private transient int mti;
    
    static {
        MAGIC = new int[] { 0, -1727483681 };
    }
    
    public MTRandom() {
        super();
        this.compat = false;
    }
    
    public MTRandom(final long n) {
        super(n);
        this.compat = false;
    }
    
    public MTRandom(final boolean compat) {
        super(0L);
        this.compat = compat;
        long currentTimeMillis;
        if (compat) {
              currentTimeMillis = 5489L;
        }
        else {
            currentTimeMillis = System.currentTimeMillis();
        }
        this.setSeed(currentTimeMillis);
    }
    
    public MTRandom(final byte[] seed) {
        super(0L);
        this.compat = false;
        this.setSeed(seed);
    }
    
    public MTRandom(final int[] seed) {
        super(0L);
        this.compat = false;
        this.setSeed(seed);
    }
    
    public static int[] pack(final byte[] array) {
        final int length = array.length;
        final int n = array.length + 3 >>> 2;
        final int[] array2 = new int[n];
        int n2;
        for (int i = 0; i < n; i = n2) {
            n2 = i + 1;
            int n3;
            if ((n3 = n2 << 2) > length) {
                n3 = length;
            }
           int n4;
            int n5;
            for (n4 = n3 - 1, n5 = (array[n4] & 0xFF); (n4 & 0x3) != 0x0; --n4, n5 = (n5 << 8 | (array[n4] & 0xFF))) {}
            array2[i] = n5;
        }
        return array2;
    }
    
    private void setSeed(int mti) {
        if (this.mt == null) {
            this.mt = new int[624];
        }
        this.mt[0] = mti;
        mti = 1;
        while (true) {
            this.mti = mti;
            mti = this.mti;
            if (mti >= 624) {
                break;
            }
            final int[] mt = this.mt;
            mt[mti] = (mt[mti - 1] ^ mt[mti - 1] >>> 30) * 1812433253 + mti;
            ++mti;
        }
    }
    
  @Override
    protected final int next(final int n) {
        try {
            if (this.mti >= 624) {
                int n2 = 0;
                int i;
                while (true) {
                    i = n2;
                    if (n2 >= 227) {
                        break;
                    }
                    final int[] mt = this.mt;
                    final int n3 = mt[n2];
                    final int n4 = n2 + 1;
                    final int n5 = (Integer.MAX_VALUE & mt[n4]) | (Integer.MIN_VALUE & n3);
                    mt[n2] = (MTRandom.MAGIC[n5 & 0x1] ^ (mt[n2 + 397] ^ n5 >>> 1));
                    n2 = n4;
                }
                while (i < 623) {
                    final int[] mt2 = this.mt;
                    final int n6 = mt2[i];
                    final int n7 = i + 1;
                    final int n8 = (n6 & Integer.MIN_VALUE) | (mt2[n7] & Integer.MAX_VALUE);
                    mt2[i] = (MTRandom.MAGIC[n8 & 0x1] ^ (mt2[i - 227] ^ n8 >>> 1));
                    i = n7;
                }
                final int[] mt3 = this.mt;
                final int n9 = (Integer.MAX_VALUE & mt3[0]) | (Integer.MIN_VALUE & mt3[623]);
                mt3[623] = (MTRandom.MAGIC[n9 & 0x1] ^ (mt3[396] ^ n9 >>> 1));
                this.mti = 0;
            }
            final int n10 = this.mt[this.mti++];
            final int n11 = n10 ^ n10 >>> 11;
            final int n12 = n11 ^ (n11 << 7 & 0x9D2C5680);
            final int n13 = n12 ^ (n12 << 15 & 0xEFC60000);
            return (n13 ^ n13 >>> 18) >>> 32 - n;
        }
        
        finally {}
    }
    
    @Override
    public final void setSeed(final long n) {
        synchronized (this) {
            if (this.compat) {
                this.setSeed((int)n);
            }
            else {
                if (this.ibuf == null) {
                    this.ibuf = new int[2];
                }
                final int[] ibuf = this.ibuf;
                ibuf[0] = (int)n;
                ibuf[1] = (int)(n >>> 32);
                this.setSeed(ibuf);
            }
        }
    }
    
    public final void setSeed(final byte[] array) {
        this.setSeed(pack(array));
    }
    
    public final void setSeed(int[] mt) {
        try {
            final int length = mt.length;
            if (length != 0) {
                int n;
                if (624 > length) {
                    n = 624;
                }
                else {
                    n = length;
                }
                this.setSeed(19650218);
                int n2 = 1;
                int n3 = 0;
                int n7;
                for (int i = n; i > 0; --i, n3 = n7) {
                    final int[] mt2 = this.mt;
                    final int n4 = mt2[n2];
                    final int n5 = n2 - 1;
                    mt2[n2] = (n4 ^ (mt2[n5] >>> 30 ^ mt2[n5]) * 1664525) + mt[n3] + n3;
                    final int n6 = n2 + 1;
                    ++n3;
                    if ((n2 = n6) >= 624) {
                        mt2[0] = mt2[623];
                        n2 = 1;
                    }
                    if ((n7 = n3) >= length) {
                        n7 = 0;
                    }
                }
                for (int j = 623; j > 0; --j) {
                    mt = this.mt;
                    final int n8 = mt[n2];
                    final int n9 = n2 - 1;
                    mt[n2] = (n8 ^ (mt[n9] >>> 30 ^ mt[n9]) * 1566083941) - n2;
                    if (++n2 >= 624) {
                        mt[0] = mt[623];
                        n2 = 1;
                    }
                }
                this.mt[0] = Integer.MIN_VALUE;
                return;
            }
            throw new IllegalArgumentException("Seed buffer may not be empty");
        }
        finally {}
    }
}

You can run it here in OnlineGDB and it’ll give you the flag as the output.

Flag: grey{hum4n_m3m0ry_i5_4lw4y5_b3tt3r_th4n_r4nd0m_4cc3ss_m3m0ry}

🍭 Parcel

The binary is packed. Try to decompile it and you will find unreadable code.

Find out how it is packed and unpack it to recover the original binary.

MD5 (parcel) = 903adf25f54687ef3d875b51442e20df

  • daniellimws

Hint: Run strings on the binary. There are some interesting strings in there.

Attached: parcel

1
parcel: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, no section header

If we run strings on the provided ELF binary as hinted, we’ll see the strings “UPX!”.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
a@a:/grey$ strings parcel | tail
/@1dC
?88l
XX$/
`?2
7dCI
6{$O
.?      ;
/QqQ
UPX!
UPX!

This means that the ELF is likely packed with UPX. If vanilla UPX is used, such binaries can simply be unpacked with the official upx binary itself by adding the -d flag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
C:\grey>upx -d parcel -o unpackedparcel
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2020
UPX 3.96w       Markus Oberhumer, Laszlo Molnar & John Reiser   Jan 23rd 2020

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
    428392 <-    105224   24.56%   linux/amd64   unpackedparcel

Unpacked 1 file.
1
2
a@a:/grey$ file unpackedparcel
unpackedparcel: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=f616d88f62c910d6dddddd732eb503f9c61fa88a, for GNU/Linux 3.2.0, not stripped

Ok now where’s the flag? If we run parcel or unpackedparcel, it’ll ask us for the address of certain functions like h12. Now that we have the unpacked unstripped binary unpackedparcel, we can get those addresses easily using nm -t d unpackedparcel | grep <function_name>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
a@a:/grey$ nm -t d unpackedparcel | grep h12
0000000004207125 T h12
a@a:/grey$ nm -t d unpackedparcel | grep t80
0000000004221073 T t80
a@a:/grey$ nm -t d unpackedparcel | grep g20
0000000004206113 T g20
a@a:/grey$ ./parcel
Tell me the address of the function h12 (in decimal): 4207125
Tell me the address of the function t80 (in decimal): 4221073
Tell me the address of the function g20 (in decimal): 4206113
Congrats! grey{d1d_y0u_us3_nm_0r_objdump_0r_gdb_0r_ghidra_0r_rizin_0r_ida_0r_binja?}

Flag: grey{d1d_y0u_us3_nm_0r_objdump_0r_gdb_0r_ghidra_0r_rizin_0r_ida_0r_binja?}

Misc

Ghost

I tried to send you a bunch of messages :)

MD5 (ghost) = 3f61d4a3d19c3f3ac1f8223f7c7672af

  • Tensor

Attached: ghost

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
a@a:/grey$ cat ghost
I got message for you:

NTQgNjggNjkgNzMgMjAgNjkgNzMgMjAgNmUgNmYgNzQgMjAgNjkgNzQgMjAgNmQgNjEgNmUgMmMgMjAgNzQgNzIgNzkgMjAgNjggNjEgNzIgNjQgNjUgNzIgMjE=

++++++++++[>+>+++>+++++++>++++++++++<<<<-]>>>++++++++++++++.>++++.+.++++++++++.<<++.>>----------.++++++++++.<<.>>----------.+++++++++++.<---------------------.<.>>------.-------------.+++++++.

pi pi pi pi pi pi pi pi pi pi pika pipi pi pipi pi pi pi pipi pi pi pi pi pi pi pi pipi pi pi pi pi pi pi pi pi pi pi pichu pichu pichu pichu ka chu pipi pipi pipi pipi pi pi pi pi pi pi pi pi pikachu pi pi pi pikachu ka ka ka pikachu pichu pichu pi pi pikachu pipi pipi pi pi pikachu pi pikachu









KRUGS4ZANFZSA3TPOQQHI2DFEBTGYYLHEBWWC3RAIQ5A====

synt{abgGungFvzcyr:C}

Me: Message received.

The text file given contains a few different kinds of encoded messages, but the huge chunk of empty spaces/lines in the middle are the most suspicious. Let’s take a closer look at the hex values used.

 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
a@a:/grey$ xxd -s 0x270 ghost
00000270: 7069 2070 696b 6163 6875 2070 6920 7069  pi pikachu pi pi
00000280: 6b61 6368 750d 0a20 0909 2020 0909 0920  kachu.. ..  ...
00000290: 0909 0920 2009 2020 0909 2020 0920 0920  ...  .  ..  . .
000002a0: 0909 0909 2020 0920 0909 0909 2009 0920  ....  . .... ..
000002b0: 0909 2020 0909 0920 0909 2009 2020 2020  ..  ... .. .
000002c0: 2009 0920 2020 2020 0909 0920 2009 0920   ..     ...  ..
000002d0: 2009 0920 0909 0920 0920 0909 0909 0920   .. ... . .....
000002e0: 0909 2020 2009 2020 0909 0909 2020 0920  ..   .  ....  .
000002f0: 0909 0920 0920 2020 2009 0920 2009 0920  ... .    ..  ..
00000300: 2009 2020 0920 2020 0920 0909 0909 0920   .  .   . .....
00000310: 0909 2009 0909 2020 2009 0920 2020 2020  .. ...   ..
00000320: 0909 0920 0920 2020 0920 0909 0909 0920  ... .   . .....
00000330: 2009 0920 2020 0920 0909 2009 0909 2020   ..   . .. ...
00000340: 0909 0920 0909 2020 0909 2009 2020 0920  ... ..  .. .  .
00000350: 0909 0920 2009 0920 0920 2009 2020 0920  ...  .. .  .  .
00000360: 0909 2020 2009 2020 0909 2009 0920 2020  ..   .  .. ..
00000370: 2009 0920 2009 0920 0909 0909 0920 090d   ..  .. ..... ..
00000380: 0a4b 5255 4753 345a 414e 465a 5341 3354  .KRUGS4ZANFZSA3T
00000390: 504f 5151 4849 3244 4645 4254 4759 594c  POQQHI2DFEBTGYYL
000003a0: 4845 4257 5743 3352 4149 5135 413d 3d3d  HEBWWC3RAIQ5A===
000003b0: 3d0d 0a0d 0a73 796e 747b 6162 6747 756e  =....synt{abgGun
000003c0: 6746 767a 6379 723a 437d 0d0a 0d0a 4d65  gFvzcyr:C}....Me
000003d0: 3a20 4d65 7373 6167 6520 7265 6365 6976  : Message receiv
000003e0: 6564 2e0d 0a                             ed...

The empty space chunk consists of only 2 values — 0x20s and 0x09s, so my first thought was that it represented either binary encoding or morse code. Brief experimentation in CyberChef will reveal that it is indeed binary encoding where the 0x20s represent 0s and the 0x09s represent 1s. Here is the decoding done in CyberChef.

Flag: grey{gh0s7_byt3$_n0t_1nvisIbl3}

Calculator

Calculator is a nice invention

  • mechfrog88

US Instance: nc 35.184.68.167 15521

EU Instance: nc 34.77.124.65 15521
nc challs.nusgreyhats.org 15521

 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
a@a:~$ nc challs.nusgreyhats.org 15521

           __________
         .'----------`.
         | .--------. |
         | |########| |       __________
         | |########| |      /__________\
.--------| `--------' |------|    --=-- |-------------.
|        `----,-.-----'      |o ======  |             |
|       ______|_|_______     |__________|             |
|      /  %%%%%%%%%%%%  \                             |
|     /  %%%%%%%%%%%%%%  \                            |
|     ^^^^^^^^^^^^^^^^^^^^                            |
+-----------------------------------------------------+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

How fast are you on calculating basic math expression?
Answer 100 math questions in 30 seconds to the flag

Operations include:
1. add x y -> returns x + y
2. mul x y -> returns x * y
3. sub x y -> returns x - y
4. neg x -> returns -x
5. inc x -> returns x + 1

Example1: mul add 1 2 sub 5 1
Ans1: (1 + 2) * (5 - 1) = 12

Example2: add mul sub 3 2 inc 5 3
Ans2: (3 - 2) * (5 + 1) + 3 = 9

Send START when you are ready!

Prefix notation is used to evaluate these math expressions, so I just took a Python prefix calculator script from https://stackoverflow.com/a/5307565 and edited the tokens to match those required in this challenge:

 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
from collections import deque
from pwn import *


def parse(tokens):
    token=tokens.popleft()
    if token==b'add':
            return parse(tokens)+parse(tokens)
    elif token==b'sub':
            return parse(tokens)-parse(tokens)
    elif token==b'mul':
            return parse(tokens)*parse(tokens)
    elif token==b'neg':
            return parse(tokens)*(-1)
    elif token==b"inc":
            return parse(tokens)+1
    else:
            # must be just a number
            return int(token)


if __name__=='__main__':
    conn = remote('challs.nusgreyhats.org', 15521)
    conn.recvuntil(b'!')
    conn.recvline()
    conn.sendline(b'START')
    for i in range(100):
        problem = conn.recvline()
        problem = problem.rstrip(b'\n')
        answer = (parse(deque(problem.split())))
        conn.sendline(bytes(str(answer), 'ascii'))
        conn.recvline()
    result = conn.recvline()
    print(result)
    flag = conn.recvline()
    print(flag)

Script output:

1
2
3
4
5
a@a:/grey$ python3 prefix_calculator.py
[+] Opening connection to challs.nusgreyhats.org on port 15521: Done
b'You done it in 21.74351406097412 seconds!\n'
b'grey{prefix_operation_is_easy_to_evaluate_right_W2MQshAYVpGVJPcw}\n'
[*] Closed connection to challs.nusgreyhats.org port 15521

Flag: grey{prefix_operation_is_easy_to_evaluate_right_W2MQshAYVpGVJPcw}

🍭 Firmware

Router firmware is a nice target to start your bug hunting journey. But you have to first understand how the firmware is loaded.

There is always a file in the firmware image that tells the router what services to start. Find this file.

MD5 (firmware.img.gz) = 488d36e3855f16972adec9067ca6deb2

  • daniellimws

Attached: firmware.img.gz

I ran binwalk -e on firmware.img to extract the Squashfs filesystem from the image so that we can find the file we are told to look for.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
a@a:/grey$ binwalk -e firmware.img

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
1105744       0x10DF50        uImage header, header size: 64 bytes, header CRC: 0x39150100, created: 1978-08-22 13:45:13, image size: 204509413 bytes, Data Address: 0x33FF2FE1, Entry Point: 0x2099E5, data CRC: 0x52E3, OS: 4.4BSD, image name: ""
1164816       0x11C610        CRC32 polynomial table, little endian
1187240       0x121DA8        device tree image (dtb)
1189888       0x122800        uImage header, header size: 64 bytes, header CRC: 0x2E68CDF5, created: 2021-10-24 09:01:35, image size: 596535 bytes, Data Address: 0x4000000, Entry Point: 0x4000000, data CRC: 0x3982880E, OS: Firmware, CPU: ARM, image type: Firmware Image, compression type: none, image name: "U-Boot 2019.07 for zynq board"
1191944       0x123008        CRC32 polynomial table, little endian
1524744       0x174408        device tree image (dtb)
1549184       0x17A380        SHA256 hash constants, little endian
1772908       0x1B0D6C        device tree image (dtb)
1789952       0x1B5000        device tree image (dtb)
1790180       0x1B50E4        gzip compressed data, maximum compression, from Unix, last modified: 1970-01-01 00:00:00 (null date)
5248724       0x5016D4        MySQL MISAM index file Version 10
6232576       0x5F1A00        device tree image (dtb)
18874368      0x1200000       Squashfs filesystem, little endian, version 4.0, compression:xz, size: 2285698 bytes, 840 inodes, blocksize: 262144 bytes, created: 2021-10-24 09:01:35

After that, I just searched for the flag fragment grey in the extracted filesystem using ripgrep and the flag was found in /etc/inittab.

1
2
3
a@a:/grey/_firmware.img.extracted/squashfs-root$ rg grey
etc/inittab
5:# you found me! grey{inittab_1s_4n_1mp0rt4nt_pl4c3_t0_l00k_4t_wh3n_r3v3rs1ng_f1rmw4r3}

Flag: grey{inittab_1s_4n_1mp0rt4nt_pl4c3_t0_l00k_4t_wh3n_r3v3rs1ng_f1rmw4r3}

🍭 Image Upload

HTTP is not secure. Inspect this packet dump and you will know why.

Find the packet related to an image upload and extract the image. Then, find the name of the creator of this image.

MD5 (dump.pcap) = 2565cf775ac71c217102ace91cc922ec

  • daniellimws

Attached: dump.pcap

Open the given PCAP in Wireshark and use File -> Export Objects -> HTTP to view HTTP objects transmitted.

Wireshark HTTP object shows 'ctf.png' with flag string in POST request

There’s a ctf.png in the multipart/form-data HTTP object with the flag string in a PNG tEXt chunk labelled “Author”.

Flag: grey{wireshark_exiftool_are_good}

Web

🍭 Too Fast

Something went by too quickly!

  • Dragonym

hxxp://challs.nusgreyhats.org:14004/

I analyzed the given website but nothing stood out in the HTML source or the HTTP request/response headers, so I ran a “Discover content” scan with Burp Suite and it quickly found /admin.php with the flag in the response body.

Request:

1
2
3
4
5
6
7
8
GET /admin.php HTTP/1.1
Host: challs.nusgreyhats.org:14004
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en-US;q=0.9,en;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36
Connection: close
Cache-Control: max-age=0

Response:

1
2
3
4
5
6
7
8
9
HTTP/1.1 302 Found
Host: challs.nusgreyhats.org:14004
Date: Fri, 10 Jun 2022 13:27:20 GMT
Connection: close
X-Powered-By: PHP/7.4.29
Location: ./index.php
Content-type: text/html; charset=UTF-8

<p><span style='color: #ffffff;'>grey{why_15_17_571LL_ruNn1n_4Pn39Mq3CQ7VyGrP}</span></p>

Flag: grey{why_15_17_571LL_ruNn1n_4Pn39Mq3CQ7VyGrP}


Thanks for reading :)

Share on

rainbowpigeon
WRITTEN BY
rainbowpigeon
OSCP | OSED | Burp Suite Certified Practitioner