In the handout, we are given the main vm.html. It appears to be from a project called x86CSS. The project appears to be a 16bit x86 8086 emulator. Reading through the build script in build_css.py, we can see the code that actually embeds the executable into the file:

for i in range(MEM_SIZE):
  variables.append(createChosenMemoryInt(f"m{i}", i, True, 0x90 if i < PROG_OFFSET else 0))

The README describes the VM as having 0x600 bytes of memory, with the program starting at 0x100. We know that 0x90 is the one-byte no operation opcode for x86 systems, so we can safely assume that this code is what is in charge of writing the program in. If we copy a hunk of the CSS into regex101, we can write a custom pattern that searches for all of the individual values. We can then extract these values as such:

import re

with open("handout/vm.html", "rb") as f:
    m = re.findall(rb"@property --m\d+ \{.*?initial-value: (\d+);", f.read(), re.S)

with open("program.bin", "wb") as f:
    f.write(bytes(map(int, m[0x100:0x600])).rstrip(b"\0")) # start from 0x100 and memory is 0x600 long

We can successfully extract the x86 executable it internally runs. Now, we just need to decompile it.

Ghidra import options

Move your cursor to 0x100, hit D to decompile. Then, hit F to turn it into a function. In function view, we can see the decompiled C source.

undefined2 __cdecl16near FUN_0000_0100(void)
{
  char cVar1;
  undefined1 extraout_AH;
  undefined2 uVar2;
  uint uVar3;
  uint uVar4;
  undefined2 unaff_DS;
  bool bVar5;
  int local_12;
  undefined2 local_10;
  undefined2 local_e;
  byte local_c;
  byte local_b;
  byte local_a;
  byte local_9;
  
  (*(code *)*(undefined2 *)0x33c)(0x2ea);
  (*(code *)*(undefined2 *)0x33c)(0x2f3);
  (*(code *)*(undefined2 *)0x33e)(0x2fc);
  uVar4 = 0;
  do {
    *(undefined2 *)*(undefined2 *)0x338 = 2;
    local_10 = 0x131;
    local_10 = (*(code *)*(undefined2 *)0x33a)();
    cVar1 = (char)local_10;
    uVar3 = uVar4;
    if (cVar1 != '\0') {
      *(undefined2 *)*(undefined2 *)0x338 = 0;
      if (cVar1 == '\n') break;
      uVar3 = uVar4 + 1;
      *(char *)((int)&local_10 + uVar4) = cVar1;
      local_10 = (*(code *)*(undefined2 *)0x340)();
    }
    uVar4 = uVar3;
  } while ((int)uVar3 < 10);
  local_10._1_1_ = (byte)((uint)local_10 >> 8);
  local_10._0_1_ = 10;
  (*(code *)*(undefined2 *)0x340)();
  if ((((((int)uVar3 < 9) && ((uVar3 & 1) == 0)) && ((local_a ^ (byte)local_e) == 0x6f)) &&
      (((((local_e._1_1_ & local_9) == 0 && ((local_e._1_1_ | local_9) == 0x7f)) &&
        (((local_a | local_e._1_1_) == 0x3f &&
         (((local_10._1_1_ & local_b) == 0x40 && (((byte)local_e ^ local_b) == 0x11)))))) &&
       (((byte)local_e ^ local_10._1_1_) == 8)))) &&
     ((((local_10._1_1_ & local_c) == 0x10 &&
       ((((((byte)local_e & local_10._1_1_) != 0x51 || ((local_a ^ (byte)local_10) != 4)) ||
         ((local_9 | local_c) != 0x76)) ||
        (((((byte)local_e | local_9) == 0x5f && ((local_e._1_1_ | local_c) == 0x39)) &&
         (((local_b ^ local_e._1_1_) == 0x71 && ((local_10._1_1_ & local_a) == 0x10)))))))) &&
      (((((local_9 ^ local_c) == 0x76 && ((local_e._1_1_ & (byte)local_10) == 0x30)) &&
        (((byte)local_e ^ local_c) == 0x69)) &&
       ((((((byte)local_e & local_10._1_1_) == 0x51 && (((byte)local_e | local_c) == 0x79)) &&
         ((local_c ^ (byte)local_10) == 2)) && ((local_a ^ local_e._1_1_) == 0xf)))))))) {
    local_e = (undefined2 *)0x301;
    local_10 = 0x274;
    (*(code *)*(undefined2 *)0x33c)();
    local_e = (undefined2 *)0x2e0;
    local_10 = 0x27c;
    (*(code *)*(undefined2 *)0x33e)();
    local_e = (undefined2 *)0x30a;
    local_10 = 0x284;
    (*(code *)*(undefined2 *)0x33c)();
    local_e = (undefined2 *)0x313;
    local_10 = 0x28c;
    (*(code *)*(undefined2 *)0x33c)();
    local_e = (undefined2 *)0x31c;
    local_10 = 0x294;
    (*(code *)*(undefined2 *)0x33c)();
    local_e = (undefined2 *)0x325;
    local_10 = 0x29c;
    (*(code *)*(undefined2 *)0x33c)();
    local_e = (undefined2 *)0x2e5;
    local_10 = 0x2a4;
    (*(code *)*(undefined2 *)0x33e)();
    local_e = &local_10;
    local_10 = 0x2ac;
    (*(code *)*(undefined2 *)0x33c)();
    local_e = (undefined2 *)CONCAT11(extraout_AH,0x7d);
    local_10 = 0x2b3;
    (*(code *)*(undefined2 *)0x340)();
    local_12 = 1000;
    do {
      bVar5 = local_12 != 0;
      local_12 = local_12 + -1;
    } while (bVar5);
    uVar2 = 0x43;
  }
  else {
    local_e = (undefined2 *)0x32e;
    local_10 = 0x2d7;
    (*(code *)*(undefined2 *)0x33c)();
    uVar2 = 0xffbd;
  }
  return uVar2;
}

We can probably extrapolate that the big bad evil do {} while () block just reads all the input. We can see that the while condition is (int)uVar3 < 10, meaning that it will read 8 characters + 1 newline.

In between the input loop and the main condition, we see local_10._1_1_ = (byte)((uint)local_10 >> 8); local_10._0_1_ = 10;; mentally file that away for later.

The MASSIVE if block looks super scary, but we can break the conditions down. The very first condition, ((int)uVar3 < 9) && ((uVar3 & 1) == 0), requires that the input be at most 8 characters and of even length, further suggesting that the password is 8 characters long.

So what are these 8 characters? Well, Ghidra generates variables in order. If we look after the int local_12 variable, we find two undefined2 variables and four byte variables. Think back to literally 30 seconds ago. The middle hunk of code local_10._1_1_ = (byte)((uint)local_10 >> 8); local_10._0_1_ = 10; was taking that undefined2 and breaking it down into two bytes. This further supports our theory. Let’s give it a try.

Think back to when we imported this executable. Everything is little endian, so the lower byte of (byte)local_10 is our first character, and the shifted local_10._1_1_ is our second character. Similarly, with the local_e variable, (byte)local_e is our third character, and the shifted local_e._1_1_ is our second character. Lastly, here come fifth: local_c, sixth: local_b, seventh: local_a, and eight: local_9. Here’s a neat little chart:

IndexExpression
1(byte)local_10
2local_10._1_1_
3(byte)local_e
4local_e._1_1_
5local_c
6local_b
7local_a
8local_9

With this decoded, we can now translate that MASSIVE if condition.

(
	(
		(
			((int)uVar3 < 9) &&
			((uVar3 & 1) == 0)
		) &&
		((local_a ^ (byte)local_e) == 0x6f)
	) &&
	(
		(
			(
				(local_e._1_1_ & local_9) == 0 &&
				(local_e._1_1_ | local_9) == 0x7f
			) &&
			(
				(local_a | local_e._1_1_) == 0x3f &&
				(
					(local_10._1_1_ & local_b) == 0x40 &&
					((byte)local_e ^ local_b) == 0x11
				)
			)
		) &&
		((byte)local_e ^ local_10._1_1_) == 8
	)
) &&
(
	(
		(local_10._1_1_ & local_c) == 0x10 &&
		(
			(
				(
					((byte)local_e & local_10._1_1_) != 0x51 ||
					(local_a ^ (byte)local_10) != 4
				) ||
				(local_9 | local_c) != 0x76
			) ||
			(
				(
					(byte)local_e | local_9
				) == 0x5f &&
				(local_e._1_1_ | local_c) == 0x39
			) &&
			(
				(local_b ^ local_e._1_1_) == 0x71 &&
				(local_10._1_1_ & local_a) == 0x10
			)
		)
	) &&
	(
		(
			(local_9 ^ local_c) == 0x76 &&
			(local_e._1_1_ & (byte)local_10) == 0x30
		) &&
		((byte)local_e ^ local_c) == 0x69
	) &&
	(
		(
			((byte)local_e & local_10._1_1_) == 0x51 &&
			((byte)local_e | local_c) == 0x79
		) &&
		((local_c ^ (byte)local_10) == 2) &&
		((local_a ^ local_e._1_1_) == 0xf)
	)
)

We want to focus on XOR operations because they give us the most information and are the easiest to work with algebraically. isolating the conditions and solving:

7 ^ 3 = 0x6F
3 ^ 6 = 0x11
3 ^ 2 = 0x08
2 & 5 = 0x10
8 ^ 5 = 0x76
3 ^ 5 = 0x69
3 & 2 = 0x51
5 ^ 1 = 0x02
7 ^ 4 = 0x0F

3 ^ 2 = 0x08
3 & 2 = 0x51
{3, 2} = {0x59, 0x51}

3 =? 0x51 => 2 =? 0x59
5 =? 0x51 ^ 0x69 =? 0x38
0x59 & 0x38 = 0x18 != 0x10

3 = 0x59
2 = 0x51
5 = 0x59 ^ 0x69 = 0x30

7 = 0x6F ^ 0x59 = 0x36
6 = 0x59 ^ 0x11 = 0x48
8 = 0x76 ^ 0x30 = 0x46
1 = 0x30 ^ 0x02 = 0x32
4 = 0x36 ^ 0x0F = 0x39

The final string becomes

[0x32, 0x51, 0x59, 0x39, 0x30, 0x48, 0x36, 0x46] = "2QY90H6F"

If we input this password into the VM, we find it works, returning the flag.

gaslightCTF{ch3ck_0ut_lyra-horse!!_2QY90H6F}