UMass CTF 2024
This is the third times I write challenge for UMassCTF. This year, I bring 3 rev challenges plus 1 miscs. In the following sections, I will comment and show solution for each challenge in the order of number of solves. Be sure to try the challenge first before reading.
[miscs] Agent P
Description
- Final points: 449
- Solves: 37
Patrick sees secret, he puts it into computer immediately. But can you read the flag?
Files:
- secret.tar.gz
Comment
Originally, this challenge does not have a name. When the CTF is starting to get themed, I decided to go with Agent P, which uses the initial letter of Patrick.
Like the name, I did not engineer this challenge too hard
and went with giving player a single line of encoded text. If during
testing it was said to be easy, I would have racked up the challenge
difficulty by making a python server to serve encoded text and expect
correctly decoded result from player before returning the flag.
I finalized the challenge by making sure each character has a clear
mapping.
Compressed using tar
gunzip under user p and tabs at the beginning
are completely unrelated stuff in solving.
Surprisingly still, it became a survey of computer users in the CTF and has the highest points within misc category.
Solution
With the given sequence of character, we plot them on ANSI QWERTY keyboard layout (US). You may search online for an example of the layout, or look down if you happen to use a compatible layout.
[rev] Fructose
Description
- Final points: 454
- Solves: 35
Every once in a while, it is good to have fruit.
Files:
- fructose.zip
Comment
It starts out with a realization that Ghidra decompiler would
regard push before ret
as dead code and ignore it.
At the same time, I was learning about x86 ROP chain,
and immediately I combine both ideas into a challenge.
Here are the cool stuff I have done when making this challenge:
push rax; ret
. Theret
instruction basically pop the top of the stack and jump to it, so here we usepush rax
to slip in the address we want to go to. When opened in Ghidra, this part would be marked as dead code and nothing will show up. You can certainly use any other volatile register instead of justrax
.- Disturbing bytes. Commonly, the disassembler assumes x86 code to be lined up consecutively, meaning the following bytes after an instruction should also construct another instruction. Adding one or two bytes before a jump target and the assembler will fail to decode original instruction.
- String and data in
.text
. Why not? Apparently, this is not limited to x86. Any von Neumann architecture should be able to pull the same trick. - Hybrid bytes.
The format string prefix
%
is represented by byte 0x25, which is also the opcode for AND instruction in x86. Adding 4 more bytes and this turns intoand eax, imm32
. It does not affect overall program just like junk instructions, but it can also be used as a proper format string. Certainly, this trick is not limited to%
. And this method is also used in an inverse way to fake some string to look like actual instructions. - Incremental switch table. Instead of having different jump targets
that each has
ret
at the end, I merge all of them into one. Knowing the differences between targets for my challenge is the adding amount, I convert them into a series ofinc eax
, then set the proper jump target. - Address compacting. The simplest way to build a jump table is to store addresses in an array, which compiler will do by default for switch statement (in some optimizations). However, our jump targets are small and close together, so we do not need to store full 64-bit addresses. Here I manually cut the address in half and store the offset into a table, then use a register as the base. Since memory addressing syntax in x86 includes base and offset already, implementing this is nothing but convenient. This is inspired by pointer compression in Chrome V8.
call
instruction (E8). If you simply thinkcall
as a branch instruction withpush rip
, there are many fancy ways you can play around. Beyond this challenge, you may also use it as a way to retrieve current rip (PC).loop
instruction (E2) andjrcxz
instruction (E3). These are convenient instructions for looping and branching without relying on flag register. Both depends on the conditionrcx == 0
. Therep
(Repeat String Operation Prefix) has the same property, but I was not able to put it cleanly in this challenge. And I am curious why these would exist in x86.- Fake conditional branch. Instead of always branching by using
jmp
, we can slightly reduce the reliability by using a conditional branch with a condition that has a significant high probabiliity. int3
. This is normally how a debugger set breakpoint: inserting this instruction right before where you set breakpoint. If you are already developing on x86 assembly, you can just directly insertint3
to set breakpoint and it would be caught by debugger.
The given executable is written in x86-64 assembly, compiled using Netwide Assembler, linked using LLVM lld, and targeting Windows 64-bit. The repo for the challenge is public at dungwinux/fructose.
In the CTF, this is my most well-received challenge. Unfortunately, a player spotted an unintended solution. As it is pretty obvious, I would not explain in details here.
Solution
Key ideas to look up in the binary: ROP, jump into middle of the instruction
There are 3 stages:
- Stage 1: main uses
push rax
to go to next function - Stage 2: First half of the function is hidden using useless bytes. You may need to manually fix the disassembly view in the debugger/disassembler/RE tool.
- Stage 3: Here are ROP table that maps input string using
inc
, and a function that accumulates usingxor
,add
and compares the result with stored data.
Following the correct control flow, we should see a series of character checks. Reverse each of the check, we shall recover the whole flag.
A quick way to go through most of these stage is to run this through a debugger (WinDbg or x64dbg) and set a breakpoint properly after the ROP table (slowly stepping through each instruction is also possible to reach this place). We should see each check as the program popping function address after function address from the stack.
[rev] Maltodextrin
Description
- Final points: 491
- Solves: 16
Maltodextrin is popular; I wish this one can be popular too.
Files:
- maltodextrin.zip
Comment
I never expressed this on the blog before, but this is the ISA I love the second most.
Since people are already familiar with x86 and arm assembly, plus the fact that LLM is being used extensively, I felt the need to introduce something different but at core still requiring the skill of reading assembly. That is why IA-64 become part of the challenge.
I did some research and found no active disassemblers for this architecture except for one: yaxpeax-dis. No decompilers I found can accept this architecture, so I choose to deliver an assembly file of C code compiled to Itanium instead of a binary. In order to do this, I install VS 2010, which is the last version to include an Itanium compiler.
To scale down the difficulty of reading this oddball ISA,
I wrote the C++ program to be very simple and used some patterns to
make the generated assembly easier to guess such as parallelable code.
The assembly listing by cl.exe
also added a bunch of helpful comments
highlighting constants and symbols.
Since the source code is very simple, I will include it below. It is amazing to see how the compiler parallelize this for Itanium.
You should notice the significant amount of nop
instructions in
the challenge.
#include "stdafx.h"
#define SIZE 100
int main(int argc, char* argv[])
{
unsigned char result[] = {
0x4c,0xb3,0x58,0xad,0x4a,0x85,0x4d,0x96,0x28,0x8d,0x46,0xcf,
0x77,0x8d,0x6d,0x8c,0x6c,0x9d,0x6d,0xcf,0x29,0x90,0x46,0xad,
0x2a,0x8a,0x46,0xbf,0x6b,0x9d,0x71,0xcf,0x6d,0xcd,0x7a,0x8a,
0x6c,0x8c,0x2a,0xa1,0x28,0x8d,0x46,0xcd,0x49,0xcf,0x5a,0x83,
};
char c[SIZE];
puts("Enter the secret code");
scanf("%100s", c);
for (unsigned long long i = 0; i < SIZE; i += 2) {
c[i] ^= 0x19;
c[i + 1] ^= 0xFE;
}
puts(strncmp(c, (char*)result,
sizeof(result)/sizeof(result[0])) ? "Wrong" : "Correct");
return 0;
}
A funny thing I realized when compiling this challenge is that this old compiler
has already acknowledged the bad scanf
and recommends scanf_s
instead, which I originally thought to be a recent thing.
1>ClCompile:
1> All outputs are up-to-date.
1> maltodextrin.cpp
1>maltodextrin.cpp(14): warning C4996: 'scanf': This function or variable may be unsafe. Consider using scanf_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.
1> C:\Program Files (x86)\Microsoft Visual Studio 10.0\VC\include\stdio.h(304) : see declaration of 'scanf'
Solution
The first thing that must be observed is this is Itanium (IA-64). Good references are Intel manual and Raymond Chen’s The Old New Thing. The later is recommended if you would like to have good overall understanding of the architecture.
We should see that instructions are put into bundles of 3.
Near the end, we notice the loop described in three pack of instructions
(ends with br
instructions, and each instruction inside prefixed with predicate pxx
).
The loop contains two different xor
instructions.
Before the loop, we see a lot of interleaving mov
and nop
instructions,
then a lot of st1
(store 1 byte, pp. 1150 vol. 3). After the loop, we
can also see the strlen
function is being called
(through register r27
and b7
).
An educated guess for this part would be xoring the bytes before the loop,
and it should reveal the bytes because applying xor twice on plain text is known
to return the original input. The other thing we need to figure is the order.
A complicate way to understand this part is matching each mov
with st1
and
to know the idea of rotating registers.
It also happens that the plain text is ASCII, the XOR key size is divisble by 2, and the odd and even byte of the key have different significant bit, so we can simply list all negative ones and positive ones to have two interleaving parts of the flag. Eventually, XOR-ing in proper order shall give the flag.
[rev] Aspartame
Description
- Final points: 495
- Solves: 13
Just because you can, doesn’t mean you should.
Files:
- aspartame.zip
Hint 1
There are two ways to run the challenge.
- Boot the game by having a FAT32-formatted USB, then at the root of the storage adding the file to
efi/boot/bootx64.efi
. - Run using QEMU. Download OVMF prebuilt and extract OVMF-pure-efi.fd from the archive under path
.\usr\share\edk2.git\ovmf-x64\
. You can then start the game by executing:
qemu-system-x86_64 -m 256M -bios OVMF-pure-efi.fd -kernel bootx64.efi
Hint 2
You should try winning the game.
Comment
Initially, my idea was to integrate C# into a rev/pwn challenge when I see that dotnet from version 7 can now compile into native code. I had a working example of a buffer overflow, which you might be able to glimpse in the early commit of the repo.
Then I see an experimental compiler called bflat built on top of dotnet was able to debloat many part of the C# library and target UEFI. There is an example of a 3D game running in UEFI. I was requested to make the challenge easier this year, so I decided at the end to make an UEFI reversing challenge.
To make the idea original, using bflat I implemented a new game: Tetris. Honestly, C# is such an easy language to script game, to the extent I was able to get rendering of game boards and all tetrominos up within just a day. A simple rotation system is added as well. Then @atch2203 - a fresh member of UMass Cybersecurity Club and a tetris lover - saw it and offered to help adding SRS to this tetris game. Kudos to him because the game control became much smoother after. His contribution accounts at least 50% of the gameplay in the challenge. He also presents a plausible solution if you choose to play the game instead of reversing everything.
During the development, we learned how limited this environment is.
On real machine, there are no indications of error other than freeze.
GC does not exist, so all new
calls are just plain memory allocation.
The compiler was buggy too, where the compiler cannot properly handle
array initialized with zero-value. Our workaround was to use a different
unique number (9
) and check for that number whenever we access that
array.
Somehow, the zerolib provided by bflat defines ConsoleKey.Escape
,
but the ReadKey
function never reads it, so we have to patch the
library to recognize <Escape>
key along with many
others
(see console_key.patch
in the repo).
This also makes me realize why at boot screen you only have keybinding
of keys mostly from function row.
If we had more time, we defintely would have hacked the compiler
to include more features we wanted.
The repo for the challenge is public at dungwinux/Aspartame.
Solution
First, reverse the binary to find out the winning condition. Looking at the strings, we can find “You win” text in the game. Locating the text references is quite annoying since the text is in UTF16LE and it is addressed as part of a struct.
Here we should see the condition before the win text is printed: Drop at least 69 pieces and the game area does not have any line that contains tetrominos (basically, PC or Perfect Clear).
After knowing the fact, we can either play the game or reverse the code after printing “You win”.
If we reverse the code after “You win”, we should see a while loop that read an array,
bitshift and print out one of three characters: #
, <space>
, and \n
. Concatnating the output, we should see the QR code.
It is possible to play the game, since by observation, the random seed is always the same so the tetromino sequences is deterministic. The QR-encoded flag should be printed out on the screen. See pc solves.pdf by @atch2203 for one of the solutions.
Conclusion
I hope you, the reader, enjoy wrestling with the challenges and have fun reading on how they were made. While the infra is not up to expected, these challenges are luckily unaffected due to the nature of being static. No guarantees on what I will write for the next UMassCTF, but for certain I will see you in the next blog post.