Flare-On 2024: Catbert Ransomware
Catbert Ransomware presents a disk image with four encrypted files, and UEFI bios. I’ll run the bios in an emulator, and find the binary responsible for the shell. In there, I’ll find the decrypt function and reverse it to see how it is using code at the end of the encrypted images in a small VM to check the input password. I’ll write Python VM emulator to work through the code finding the passwords. On decrypting all three, there’s some fun in the emulated bios and the flag.
Challenge
The challenge prompt reads:
A dire situation has arisen, yet again, at FLARE HQ. One of our employees has fallen victim to a ransomware attack, and the culprit is none other than the notorious Catbert, our malevolent HR manager. Catbert has encrypted the employee’s disk, leaving behind a cryptic message and a seemingly innocent decryption utility. However, this utility is not as straightforward as it seems. It’s deeply embedded within the firmware and requires a keen eye and technical prowess to extract. The employee’s most valuable files are at stake, and time is of the essence. Please, help us out one…. last… time.
The download contains a bios.bin
and a disk.img
file:
oxdf@hacky$ 7z l CatbertRansomware.7z
...[snip]...
Date Time Attr Size Compressed Name
------------------- ----- ------------ ------------ ------------------------
2024-08-16 02:16:40 ....A 4194304 1692624 bios.bin
2024-08-16 02:30:24 ....A 4194304 disk.img
------------------- ----- ------------ ------------ ------------------------
2024-08-16 02:30:24 8388608 1692624 2 files
oxdf@hacky$ file bios.bin
bios.bin: data
disk.img
The disk.img
file is a FAT disk image:
oxdf@hacky$ file disk.img
disk.img: DOS/MBR boot sector, code offset 0x3c+2, OEM-ID "MTOO4043", sectors/cluster 2, root entries 512, sectors 8192 (volumes <=32 MB), Media descriptor 0xf8, sectors/FAT 12, sectors/track 63, heads 16, serial number 0x538247ff, unlabeled, FAT (12 bit)
I can mount it on my system, and see four files inside:
oxdf@hacky$ sudo mount disk.img /mnt
oxdf@hacky$ ls /mnt/
catmeme1.jpg.c4tb catmeme2.jpg.c4tb catmeme3.jpg.c4tb DilbootApp.efi.enc
oxdf@hacky$ file /mnt/*
/mnt/catmeme1.jpg.c4tb: data
/mnt/catmeme2.jpg.c4tb: data
/mnt/catmeme3.jpg.c4tb: data
/mnt/DilbootApp.efi.enc: data
Given the theme of ransomware, it seems obvious that there are three encrypted .jpg
images, as well as an encrypted .efi
image.
Run It
Identify UEFI
bios.bin
is a UEFI bios image. One way to see this is with strings:
oxdf@hacky$ strings bios.bin | grep EFI
((UINT32) ( (((EFI_COMMON_SECTION_HEADER *) (UINTN) (FvSection))->Size[0] ) | (((EFI_COMMON_SECTION_HEADER *) (UINTN) (FvSection))->Size[1] << 8) | (((EFI_COMMON_SECTION_HEADER *) (UINTN) (FvSection))->Size[2] << 16))) == (_gPcd_FixedAtBuild_PcdOvmfPeiMemFvSize + sizeof (*FvSection))
ASSERT_EFI_ERROR (Status = %r)
binwalk
will also show this:
oxdf@hacky$ binwalk bios.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 UEFI PI Firmware Volume, volume size: 540672, header size: 0, revision: 0, Variable Storage, GUID: FFF12B8D-7696-4C8B-85A9-2747075B4F50
540672 0x84000 UEFI PI Firmware Volume, volume size: 3440640, header size: 96, revision: 0, EFI Firmware File System v2, GUID: 8C8CE578-8A3D-4F1C-3599-896185C32DD3
540840 0x840A8 LZMA compressed data, properties: 0x5D, dictionary size: 16777216 bytes, uncompressed size: 16122000 bytes
3981312 0x3CC000 UEFI PI Firmware Volume, volume size: 212992, header size: 96, revision: 0, EFI Firmware File System v2, GUID: 8C8CE578-8A3D-4F1C-3599-896185C32DD3
3981460 0x3CC094 Microsoft executable, portable (PE)
Emulation
Initial
qemu-system-x86_64
will emulate this bios to run it with qemu-system-x86_64 -bios bios.bin
. This drops me into a window with a ransom note and a terminal:
Mount
In the info message it mentions typing fs0:
to access mounted disks. Trying to do that now just errors:
I’ll exit, and run qemu
again with -drive format=raw,file=disk.img
. This mounts the disk.img
file as fs0
:
Shell Commands
The help menu shows a couple pages of shell commands, but there’s one that jumps out as particularly interesting:
I can try it on one of the three images:
Debug
I was able to get gdb to attach to the running bios image using the instructions from this site. I’ll add some more options to my qemu
invocation:
qemu-system-x86_64 -bios bios.bin -drive format=raw,file=disk.img -serial tcp::6666,server -s -debugcon file:debug.log -global isa-debugcon.iobase=0x402
This starts it with gdb
listening on 1234 and serial listening on 6666. The serial makes it so that it doesn’t start until I connect, which is nice if I want to attach gdb
at the start. The other options set up the debug.
Then I’ll run gdb on the file I want to debug (coming next section) and do target remote :1234
. In the debug log, it’ll have the addresses of loaded efi modules:
$ grep -i shell debug.log
Loading driver at 0x00006A4A000 EntryPoint=0x00006A4A688 LinuxInitrdDynamicShellCommand.efi
Boot0002: EFI Internal Shell 0x0001
[Bds]Booting EFI Internal Shell
Loading driver at 0x00005C9C000 EntryPoint=0x00005C9C5C8 Shell.efi
I can use that to match up with what I see in Ghidra for that module as the entry
:
To break, I’ll just do:
gdb-peda$ b *(0x00005C9C000+0x312ca)
Breakpoint 6 at 0x5ccd2ca
I didn’t end up using this very much, as it was a bit of a pain to get started each time I restarted the emulation, and it wasn’t really necessary. But it was possible.
RE
Get Shell Binary
Extract Files
The best way I know of to extract a UEFI bios image is with 7z
:
oxdf@hacky$ 7z x bios.bin -obios_extracted
...[snip]...
oxdf@hacky$ ls bios_extracted/
9E21FD93 FFFFFFFF
oxdf@hacky$ ls bios_extracted/9E21FD93/
EE4E5898
oxdf@hacky$ ls bios_extracted/9E21FD93/EE4E5898/
0.raw 1.raw VOLUME
oxdf@hacky$ ls bios_extracted/9E21FD93/EE4E5898/VOLUME/
1B45CC0A.raw EbcDxe.efi Mtftp4Dxe.efi RuntimeDxe.efi UiApp
AcpiTableDxe.efi EhciDxe.efi NvmExpressDxe.efi S3Resume2Pei UsbBusDxe.efi
AmdSevDxe.efi EmuVariableFvbRuntimeDxe.efi PartitionDxe.efi S3SaveStateDxe.efi UsbKbDxe.efi
ArpDxe.efi EnglishDxe.efi PcdDxe SataController.efi UsbMassStorageDxe.efi
AtaAtapiPassThruDxe.efi Fat.efi PcdPeim ScsiBus.efi VariablePolicyDynamicCommand.efi
AtaBusDxe.efi FaultTolerantWriteDxe.efi PciBusDxe.efi ScsiDisk.efi VariableRuntimeDxe.efi
BdsDxe.efi FC510EE7.raw PciHostBridgeDxe.efi SecurityStubDxe.efi VirtHstiDxe.efi
BootGraphicsResourceTableDxe.efi FFFFFFFF PciHotPlugInitDxe.efi SetupBrowser.efi Virtio10.efi
BootScriptExecutorDxe.efi FvbServicesRuntimeDxe.efi PciSioSerialDxe.efi Shell VirtioBlkDxe.efi
CapsuleRuntimeDxe.efi GraphicsConsoleDxe.efi PcRtc.efi SioBusDxe.efi VirtioFsDxe.efi
ConPlatformDxe.efi Hash2DxeCrypto.efi PeiCore SmbiosDxe.efi VirtioGpuDxe.efi
ConSplitterDxe.efi HiiDatabase.efi PlatformDxe SmbiosPlatformDxe.efi VirtioNetDxe.efi
CpuDxe.efi httpDynamicCommand.efi PlatformPei SnpDxe.efi VirtioPciDeviceDxe.efi
CpuIo2Dxe.efi IncompatiblePciDeviceSupportDxe.efi Ps2KeyboardDxe.efi StatusCodeHandlerPei VirtioRngDxe.efi
CpuMpPei IoMmuDxe.efi QemuFwCfgAcpiPlatform.efi StatusCodeHandlerRuntimeDxe.efi VirtioScsiDxe.efi
DevicePathDxe.efi Ip4Dxe QemuKernelLoaderFsDxe.efi TcgMor.efi VirtioSerialDxe.efi
Dhcp4Dxe.efi IScsiDxe QemuRamfbDxe.efi TcpDxe.efi VlanConfigDxe
DiskIoDxe.efi LinuxInitrdDynamicShellCommand.efi QemuVideoDxe.efi TerminalDxe.efi WatchdogTimer.efi
DisplayEngine LocalApicTimerDxe.efi RamDiskDxe tftpDynamicCommand.efi XhciDxe.efi
DpcDxe.efi LogoDxe.efi ReportStatusCodeRouterPei UdfDxe.efi
DriverHealthManagerDxe Metronome.efi ReportStatusCodeRouterRuntimeDxe.efi Udp4Dxe.efi
DxeCore.efi MnpDxe.efi ResetSystemRuntimeDxe.efi UefiPxeBcDxe.efi
DxeIpl.efi MonotonicCounterRuntimeDxe.efi RngDxe.efi UhciDxe.efi
That dumps out all the .efi
binaries that compose different functionality of the bios. Each is actually a Windows executable:
oxdf@hacky$ file bios_extracted/9E21FD93/EE4E5898/VOLUME/Dhcp4Dxe.efi
bios_extracted/9E21FD93/EE4E5898/VOLUME/Dhcp4Dxe.efi: PE32+ executable (DLL) (EFI boot service driver) x86-64, for MS Windows, 6 sections
Find Shell
I want to find the binary that runs when the it starts. When I try to decrypt a file and it fails, it printed a cat in ASCII art with the string “Nope.”. Unfortunately, grep -r "Nope." bios_extracted/
doesn’t return anything.
It turns out the string is there, but as a unicode string. grep
doesn’t handle these well. I’ll try using find
to get all the files and run strings -el
on each. Then I’ll pipe the results into grep
to look for that string. It works:
oxdf@hacky$ find bios_extracted/ -type f -exec strings -el {} \; | grep -F "Nope."
Nope.
But that doesn’t tell me what name of the file is. I’ll change it to list all the files and read them into a loop, where I’ll check for the string and print the filename if it’s there:
oxdf@hacky$ find bios_extracted/ -type f | while read f; do strings -el "$f" | grep -q "Nope." && echo "$f"; done
bios_extracted/9E21FD93/EE4E5898/VOLUME/Shell/0.efi
Ghidra
Finding Decrypt Calls
I’ll open the file in Ghidra. Searching for the the “Nope.” string finds a function I’ve named decrypt_fail_nope
:
This is called from FUN_00031bc4
. In this function, there are various checks. For example, if the file isn’t found:
Or if I try to decrypt a file that isn’t one of the three images:
A bit later, there’s a check on the length of the input key, and if it isn’t 0x10, then it calls decrypt_fail_nope
:
Just after that, there’s a bunch of data set, and then a call to a function I’ve named vm_exec
:
After the vm_exec
call, if this global I’ve named success
isn’t 0, it jumps back to call decrypt_fail_nope
and return.
This is a very good indication that the key needs to be 0x10 bytes, and then it is checked in vm_exec
, and needs to set success
to 0 to decrypt.
VM Overview
The function I’ve named vm_exec
initializes a stack pointer and instruction pointer (I’ve named gip
), and then loads the 1 byte op code, increments gip
:
Then it drops into a huge table of if
statements based on the opcode
.
Images Structure
Overall Structure
The encrypted images have the following structure:
0x00: MAGIC [4 bytes]
0x04: Size of enc image [4 bytes]
0x08: Offset to VM bytecode [4 bytes]
0x0C: Size of VM Bytecode [4 bytes]
0x10: Encrypted Image
????: VM Bytecode
For example, for catmeme1.jpg.c4tb
:
“C4TB” is the magic. The size of the encrypted image is 0x00011554. Adding that to the 0x10 bytes of header, it ends at 0x1164:
The offset to the VM code is 0x00011570, which starts just after the image with some nulls to align it:
The length is 0x259, which lines up perfectly with the end of the file, as 0x11570 + 0x259 = 0x117c9.
Overwrites
I’ll note that the overwrites in Ghidra into this data line up nicely with where I see values like aa
, bb
, cc
, etc in the VM code. In fact, if I pass in 16 “A” for the key, these show up when debugging:
The key bytes are put into the VM code in the following order:
key_offsets = [5, 4, 0xc, 0xb, 0x13, 0x12, 0x1a, 0x19, 0x21, 0x20, 0x28, 0x27, 0x2f, 0x2e, 0x36, 0x35]
VM Emulation
Development Process
For me, the easiest way to approach this problem is to write a Python script to emulate the VM and then print out the commands that are run. I’ll start the script with a recognizable password, opening the encrypted file, and initializing the VM:
key = b"0123456789ABCDEF"
key_offsets = [5, 4, 0xc, 0xb, 0x13, 0x12, 0x1a, 0x19, 0x21, 0x20, 0x28, 0x27, 0x2f, 0x2e, 0x36, 0x35]
with open('catmeme1.jpg.c4tb', 'rb') as f:
binary = f.read()
assert binary[:4] == b"C4TB"
enc_image_size = int.from_bytes(binary[4:8], 'little')
bytecode_offset = int.from_bytes(binary[8:12], 'little')
bytecode_size = int.from_bytes(binary[12:16], 'little')
bc = bytearray(binary[bytecode_offset:bytecode_offset+bytecode_size])
for i, c in zip(key_offsets, key):
bc[i] = c
bc = bytes(bc)
i = 0
stack = []
mem = [None] * 256
Next I’ll start a loop over the code:
while i < len(bc):
print(f"[{i:04x}] ", end="")
match bc[i]:
case 1:
while i < len(bc):
print(f"[{i:04x}] ", end="")
match bc[i]:
case _:
raise ValueError(f"Unknown opcode {bc[i]:x}")
By having it raise an error whenever it hits an unknown opcode, now I know what to go look at. The first failure is opcode 1, which matches what I observed at the start:
oxdf@hacky$ python runvm.py
[0000] Traceback (most recent call last):
File "/media/sf_CTFs/flareon-2024/10-catbertransomware/runvm.py", line 132, in <module>
raise ValueError(f"Unknown opcode {bc[i]:x}")
ValueError: Unknown opcode 1
Now I can go into the vm_exec
function and find that code:
This code pushed two bytes from the VM code onto the stack, and increments gip
to three bytes after the opcode
. So I can add that to my emulator:
case 1:
stack.append(bc[i+2] + (bc[i+1] << 8))
print(f"1: push 0x{bc[i+1]:x}{bc[i+2]:x}")
i += 3
Now it gets stuck on 6:
oxdf@hacky$ python runvm.py
[0000] 1: push 0x00
[0003] 1: push 0x3130
[0006] Traceback (most recent call last):
File "/media/sf_CTFs/flareon-2024/10-catbertransomware/runvm.py", line 132, in <module>
raise ValueError(f"Unknown opcode {bc[i]:x}")
ValueError: Unknown opcode 6
That code is here:
This is popping two values from the stack. The first is the value, and the second is the index of memory to story that value. In Python:
case 6:
val = stack.pop()
idx = stack.pop()
mem[idx] = val
i += 1
print(f"6: mem[{idx}] = 0x{val:x}")
I’ll keep working this way until I define:
- 0x1: Push two bytes static values
- 0x5: Pop an index, and push the value from that index in memory to the stack
- 0x6: Pop a value and an index, storing the value at that index in memory.
- 0x9: Pop two values, push the sum
- 0xd: Pop two values, push the product
- 0xe: Jump to a offset defined in the next two values
- 0x10: Pop a value, and jump if it’s 0
- 0x11: Pop two values, and push 1 if they are equal or 0 otherwise
- 0x12: Pop two values, and push 1 if the first is greater than the second, otherwise 0
- 0x14: Pop two values, and push 1 if the first is less than the second, otherwise 0
- 0x18: Exit
- 0x19: Pop value into
success
- 0x1a: Pop two values, xor them, and push the result
- 0x1b: Pop two values, or them, and push the result
- 0x1c: Pop two values, and them, and push the result
- 0x1d: Pop two values, and push the second mod the first
- 0x1e: Pop two values, push the second one left shifted by the first bits
- 0x1f: Pop two values, push the second one right shifted by the first bits
- 0x21: Pop two values, rotate the second right by the first bits (works on four bytes)
- 0x24: Pop two values, rotate the second left by the first bits (works on one byte)
- 0x25: Pop two values, rotate the second right by the first bits (works on one byte)
catmeme1.jpg.c4tb
Running this produces 244 lines of output, which is long, but not too long to work through:
oxdf@hacky$ python runvm.py
[0000] 1: push 0x00
[0003] 1: push 0x3130
[0006] 6: mem[0] = 0x3130
[0007] 1: push 0x01
[000a] 1: push 0x3332
[000d] 6: mem[1] = 0x3332
[000e] 1: push 0x02
[0011] 1: push 0x3534
[0014] 6: mem[2] = 0x3534
[0015] 1: push 0x03
[0018] 1: push 0x3736
[001b] 6: mem[3] = 0x3736
[001c] 1: push 0x04
[001f] 1: push 0x3938
[0022] 6: mem[4] = 0x3938
[0023] 1: push 0x05
[0026] 1: push 0x4241
[0029] 6: mem[5] = 0x4241
[002a] 1: push 0x06
[002d] 1: push 0x4443
[0030] 6: mem[6] = 0x4443
[0031] 1: push 0x07
[0034] 1: push 0x4645
[0037] 6: mem[7] = 0x4645
[0038] 1: push 0x0a
[003b] 1: push 0x6144
[003e] 6: mem[10] = 0x6144
[003f] 1: push 0x0b
[0042] 1: push 0x7534
[0045] 6: mem[11] = 0x7534
[0046] 1: push 0x0c
[0049] 1: push 0x6962
[004c] 6: mem[12] = 0x6962
[004d] 1: push 0x0d
[0050] 1: push 0x6c63
[0053] 6: mem[13] = 0x6c63
[0054] 1: push 0x0e
[0057] 1: push 0x3165
[005a] 6: mem[14] = 0x3165
[005b] 1: push 0x0f
[005e] 1: push 0x6669
[0061] 6: mem[15] = 0x6669
[0062] 1: push 0x010
[0065] 1: push 0x6265
[0068] 6: mem[16] = 0x6265
[0069] 1: push 0x011
[006c] 1: push 0x6230
[006f] 6: mem[17] = 0x6230
[0070] 1: push 0x08
[0073] 1: push 0x03
[0076] 5: push mem[3] (0x3736)
[0077] 1: push 0x030
[007a] 0x1e: push 0x3736 << 0x30 = 0x3736000000000000
[007b] 1: push 0x02
[007e] 5: push mem[2] (0x3534)
[007f] 1: push 0x020
[0082] 0x1e: push 0x3534 << 0x20 = 0x353400000000
[0083] 0x1b: push 0x353400000000 | 0x3736000000000000 = 0x3736353400000000
[0084] 1: push 0x01
[0087] 5: push mem[1] (0x3332)
[0088] 1: push 0x010
[008b] 0x1e: push 0x3332 << 0x10 = 0x33320000
[008c] 0x1b: push 0x33320000 | 0x3736353400000000 = 0x3736353433320000
[008d] 1: push 0x00
[0090] 5: push mem[0] (0x3130)
[0091] 0x1b: push 0x3130 | 0x3736353433320000 = 0x3736353433323130
[0092] 6: mem[8] = 0x3736353433323130
[0093] 1: push 0x09
[0096] 1: push 0x07
[0099] 5: push mem[7] (0x4645)
[009a] 1: push 0x030
[009d] 0x1e: push 0x4645 << 0x30 = 0x4645000000000000
[009e] 1: push 0x06
[00a1] 5: push mem[6] (0x4443)
[00a2] 1: push 0x020
[00a5] 0x1e: push 0x4443 << 0x20 = 0x444300000000
[00a6] 0x1b: push 0x444300000000 | 0x4645000000000000 = 0x4645444300000000
[00a7] 1: push 0x05
[00aa] 5: push mem[5] (0x4241)
[00ab] 1: push 0x010
[00ae] 0x1e: push 0x4241 << 0x10 = 0x42410000
[00af] 0x1b: push 0x42410000 | 0x4645444300000000 = 0x4645444342410000
[00b0] 1: push 0x04
[00b3] 5: push mem[4] (0x3938)
[00b4] 0x1b: push 0x3938 | 0x4645444342410000 = 0x4645444342413938
[00b5] 6: mem[9] = 0x4645444342413938
[00b6] 1: push 0x012
[00b9] 1: push 0x0d
[00bc] 5: push mem[13] (0x6c63)
[00bd] 1: push 0x030
[00c0] 0x1e: push 0x6c63 << 0x30 = 0x6c63000000000000
[00c1] 1: push 0x0c
[00c4] 5: push mem[12] (0x6962)
[00c5] 1: push 0x020
[00c8] 0x1e: push 0x6962 << 0x20 = 0x696200000000
[00c9] 0x1b: push 0x696200000000 | 0x6c63000000000000 = 0x6c63696200000000
[00ca] 1: push 0x0b
[00cd] 5: push mem[11] (0x7534)
[00ce] 1: push 0x010
[00d1] 0x1e: push 0x7534 << 0x10 = 0x75340000
[00d2] 0x1b: push 0x75340000 | 0x6c63696200000000 = 0x6c63696275340000
[00d3] 1: push 0x0a
[00d6] 5: push mem[10] (0x6144)
[00d7] 0x1b: push 0x6144 | 0x6c63696275340000 = 0x6c63696275346144
[00d8] 6: mem[18] = 0x6c63696275346144
[00d9] 1: push 0x013
[00dc] 1: push 0x011
[00df] 5: push mem[17] (0x6230)
[00e0] 1: push 0x030
[00e3] 0x1e: push 0x6230 << 0x30 = 0x6230000000000000
[00e4] 1: push 0x010
[00e7] 5: push mem[16] (0x6265)
[00e8] 1: push 0x020
[00eb] 0x1e: push 0x6265 << 0x20 = 0x626500000000
[00ec] 0x1b: push 0x626500000000 | 0x6230000000000000 = 0x6230626500000000
[00ed] 1: push 0x0f
[00f0] 5: push mem[15] (0x6669)
[00f1] 1: push 0x010
[00f4] 0x1e: push 0x6669 << 0x10 = 0x66690000
[00f5] 0x1b: push 0x66690000 | 0x6230626500000000 = 0x6230626566690000
[00f6] 1: push 0x0e
[00f9] 5: push mem[14] (0x3165)
[00fa] 0x1b: push 0x3165 | 0x6230626566690000 = 0x6230626566693165
[00fb] 6: mem[19] = 0x6230626566693165
[00fc] 1: push 0x014
[00ff] 1: push 0x00
[0102] 6: mem[20] = 0x0
[0103] 1: push 0x018
[0106] 1: push 0x01
[0109] 6: mem[24] = 0x1
[010a] 1: push 0x017
[010d] 1: push 0x00
[0110] 6: mem[23] = 0x0
[0111] 1: push 0x019
[0114] 1: push 0x00
[0117] 6: mem[25] = 0x0
[0118] 1: push 0x018
[011b] 5: push mem[24] (0x1)
[011c] 1: push 0x01
[011f] 0x11: push 0x1 == 0x1
[0120] 0x10 - JNE: Did not jump to 0x241
[0123] 1: push 0x014
[0126] 5: push mem[20] (0x0)
[0127] 1: push 0x08
[012a] 0x12: push 0x0 < 0x8
[012b] 0x10 - JNE: Did not jump to 0x150
[012e] 1: push 0x015
[0131] 1: push 0x08
[0134] 5: push mem[8] (0x3736353433323130)
[0135] 1: push 0x08
[0138] 1: push 0x014
[013b] 5: push mem[20] (0x0)
[013c] 0xd: push 0x0 * 0x8 = 0x0
[013d] 0x1e: push 0x3736353433323130 >> 0x0 = 0x3736353433323130
[013e] 6: mem[21] = 0x3736353433323130
[013f] 1: push 0x016
[0142] 1: push 0x012
[0145] 5: push mem[18] (0x6c63696275346144)
[0146] 1: push 0x08
[0149] 1: push 0x014
[014c] 5: push mem[20] (0x0)
[014d] 0xd: push 0x0 * 0x8 = 0x0
[014e] 0x1e: push 0x6c63696275346144 >> 0x0 = 0x6c63696275346144
[014f] 6: mem[22] = 0x6c63696275346144
[0150] 1: push 0x014
[0153] 5: push mem[20] (0x0)
[0154] 1: push 0x07
[0157] 0x14: push 0x0 > 0x7
[0158] 0x10 - JNE: Jump to 0x17d
[017d] 1: push 0x015
[0180] 1: push 0x015
[0183] 5: push mem[21] (0x3736353433323130)
[0184] 1: push 0x0ff
[0187] 0x1b: push 0xff & 0x3736353433323130 = 0x30
[0188] 6: mem[21] = 0x30
[0189] 1: push 0x016
[018c] 1: push 0x016
[018f] 5: push mem[22] (0x6c63696275346144)
[0190] 1: push 0x0ff
[0193] 0x1b: push 0xff & 0x6c63696275346144 = 0x44
[0194] 6: mem[22] = 0x44
[0195] 1: push 0x014
[0198] 5: push mem[20] (0x0)
[0199] 1: push 0x02
[019c] 0x11: push 0x2 == 0x0
[019d] 0x10 - JNE: Jump to 0x1ac
[01ac] 1: push 0x014
[01af] 5: push mem[20] (0x0)
[01b0] 1: push 0x09
[01b3] 0x11: push 0x9 == 0x0
[01b4] 0x10 - JNE: Jump to 0x1c3
[01c3] 1: push 0x014
[01c6] 5: push mem[20] (0x0)
[01c7] 1: push 0x0d
[01ca] 0x11: push 0xd == 0x0
[01cb] 0x10 - JNE: Jump to 0x1da
[01da] 1: push 0x014
[01dd] 5: push mem[20] (0x0)
[01de] 1: push 0x0f
[01e1] 0x11: push 0xf == 0x0
[01e2] 0x10 - JNE: Jump to 0x1f1
[01f1] 1: push 0x015
[01f4] 5: push mem[21] (0x30)
[01f5] 1: push 0x016
[01f8] 5: push mem[22] (0x44)
[01f9] 0x11: push 0x44 == 0x30
[01fa] 1: push 0x00
[01fd] 0x11: push 0x0 == 0x0
[01fe] 0x10 - JNE: Did not jump to 0x208
[0201] 1: push 0x018
[0204] 1: push 0x00
[0207] 6: mem[24] = 0x0
[0208] 1: push 0x015
[020b] 5: push mem[21] (0x30)
[020c] 1: push 0x016
[020f] 5: push mem[22] (0x44)
[0210] 0x11: push 0x44 == 0x30
[0211] 0x10 - JNE: Jump to 0x220
[0220] 1: push 0x014
[0223] 1: push 0x014
[0226] 5: push mem[20] (0x0)
[0227] 1: push 0x01
[022a] 9: push 0x1 + 0x0 = 0x1
[022b] 6: mem[20] = 0x1
[022c] 1: push 0x014
[022f] 5: push mem[20] (0x1)
[0230] 1: push 0x0f
[0233] 0x14: push 0x1 > 0xf
[0234] 0x10 - JNE: Jump to 0x23e
[023e] e: jmp 0x118
[0118] 1: push 0x018
[011b] 5: push mem[24] (0x0)
[011c] 1: push 0x01
[011f] 0x11: push 0x1 == 0x0
[0120] 0x10 - JNE: Jump to 0x241
[0241] 1: push 0x017
[0244] 5: push mem[23] (0x0)
[0245] 1: push 0x010
[0248] 0x11: push 0x10 == 0x0
[0249] 0x10 - JNE: Jump to 0x253
[0253] 1: push 0x019
[0256] 5: push mem[25] (0x0)
[0257] 0x19: popped 0x0 to success
[0258] Exiting...
I’ll see at the bottom where it fails:
[0257] 0x19: popped 0x0 to success
[0258] Exiting...
This starts by pushing both the input key and a static bytes. Then it get them and does a check like this:
[0210] 0x11: push 0x44 == 0x30
[0211] 0x10 - JNE: Jump to 0x220
It’s jumping to 0x220 which is failure because my first byte of input (0x30 == “0”) doesn’t match 0x44 (“D”). If I update the first letter to be “D” and run again, now there are 341 lines of output. It does do some switches off the static value it loads at the start, but it’s pretty easy from here to get the password “DaCubicleLife101”:
The image has a partial flag:
catmeme2.jpg.c4tb
This one can be solved by the same process very quickly. I’ll have to add a couple new opcodes, and each of the bytes loaded at the beginning are XORed before compared, but working through it I’ll find the password “G3tDaJ0bD0neM4te”:
The image has more flag:
catmeme3.jpg.c4tb
Bytes 0-3
The same process works here, but it’s more complex to check. It’s no longer single byte checks. There are three checks in this one. The first looks at the first four bytes, and takes a djb2 hash of it and expects the results to be 0x7c8df4cb. I’ll write a Python script with z3
to generate possible solutions:
from z3 import *
def hash_djb2(key):
hash = BitVecVal(5381, 32)
for i in range(len(key)):
hash = (hash *33 + ZeroExt(24, key[i])) & 0xFFFFFFFF
return hash
s = Solver()
key = [BitVec(f'key_{i}', 8) for i in range(4)]
printable_constraints = [And(key[i] >= 33, key[i] <= 126) for i in range(4)]
target = 0x7c8df4cb
s.add(hash_djb2(key) == target)
s.add(printable_constraints)
while s.check() == sat:
model = s.model()
solution = [model[key[i]].as_long() for i in range(4)]
print("Solution found: ", bytes(solution).decode())
s.add(Or([key[i] != solution[i] for i in range(4)]))
This finds a bunch of options:
oxdf@hacky$ python img3-1.py
Solution found: Vg0Y
Solution found: X$QY
Solution found: X$R8
Solution found: WER8
Solution found: VfR8
Solution found: X$Pz
Solution found: WEPz
Solution found: VfPz
Solution found: X%0Y
Solution found: WF0Y
Solution found: WDs8
Solution found: WEQY
Solution found: VfQY
Solution found: WF18
Solution found: Vg18
Solution found: X%18
Solution found: WDrY
Solution found: VerY
Solution found: X#rY
Solution found: X#s8
Solution found: Ves8
Solution found: X#qz
Solution found: WDqz
Solution found: Veqz
Solution found: WF/z
Solution found: X%/z
Solution found: Vg/z
“VerY” jumps out as being an actual word.
Bytes 4-7
The next check makes sure that the input when rotated right 13 bits matches a target, 0x8b681d82. I’ll use Python and z3 again:
from z3 import *
target = 0x8b681d82
# Define the rotate function using Z3 expressions
def rotate(x):
return RotateLeft(x, 32 - 13)
key = [BitVec(f'k{i}', 8) for i in range(4)]
solver = Solver()
for k in key:
solver.add(k >= 32, k <= 126)
res = BitVecVal(0, 32)
for k in key:
res = rotate(res) + ZeroExt(24, k)
solver.add(res == target)
while solver.check() == sat:
model = solver.model()
found_key = ''.join(chr(model[k].as_long()) for k in key)
print(f"Found key: {found_key}")
solver.add(Or([k != model[k] for k in key]))
This finds two options:
oxdf@hacky$ python img3-2.py
Found key: Eu-B
Found key: DumB
“DumB” seem to go with “VerY” nicely.
Bytes 8-15
I actually originally just guessed at the last 8 bytes to get it, and managed to solve (someone had hinted to me that it was guessable). Still, it’s worth understanding what is happening.
These 8 bytes has two checks. First, they must have an Adler-32 checksum of 0xf910374. Then the fnv_hash
of the full password must be 0x31f009d2.
This can be solved using z3
, or guessing as “password”.
This image gives the third piece of the password:
Solve
On decrypting the third image, it shows a message:
The .efi
file is now decrypted:
Running it generates another image, but also gives the password:
Decrypting that file prints a message:
The rest of the flag is actual in that message, “und3r_c0nstructi0n”. The image is just another meme: