All Articles

ångstrom CTF 2023 Writeup

This page has been machine-translated from the original page.

I participated in ångstrom CTF 2023 in April with team 0nePadding.

We scored 920 points and finished in 151st place out of 1429 teams.

image-20230429174138393

I solved several Rev challenges this time, so I’ll write a brief writeup for each.

Table of Contents

zaza (Rev)

Decompiling with Ghidra produced the following simple code.

void main(void)

{
  int iVar1;
  size_t sVar2;
  long in_FS_OFFSET;
  int local_60;
  uint local_5c;
  char local_58 [72];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setbuf(stdout,(char *)0x0);
  local_60 = 0;
  local_5c = 0;
  printf("I\'m going to sleep. Count me some sheep: ");
  __isoc99_scanf(&DAT_00102092,&local_60);
  if (local_60 != 0x1337) {
    puts("That\'s not enough sheep!");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  printf("Nice, now reset it. Bet you can\'t: ");
  __isoc99_scanf(&DAT_00102092,&local_5c);
  if (local_5c * local_60 == 1) {
    printf("%d %d",(ulong)local_5c,(ulong)(local_60 + local_5c));
    puts("Not good enough for me.");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  puts("Okay, what\'s the magic word?");
  getchar();
  fgets(local_58,0x40,stdin);
  sVar2 = strcspn(local_58,"\n");
  local_58[sVar2] = '\0';
  xor_(local_58);
  iVar1 = strncmp(local_58,"2& =$!-( <*+*( ?!&$$6,. )\' $19 , #9=!1 <*=6 <6;66#",0x32);
  if (iVar1 != 0) {
    puts("Nope");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  win();
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

The key section is this:

It reads up to 0x40 bytes of user input, passes it to the xor_ function, and if the result matches 2& =$!-( <*+*( ?!&$$6,. )\' $19 , #9=!1 <*=6 <6;66#, the flag is displayed.

puts("Okay, what\'s the magic word?");
getchar();
fgets(local_58,0x40,stdin);
sVar2 = strcspn(local_58,"\n");
local_58[sVar2] = '\0';
xor_(local_58);
iVar1 = strncmp(local_58,"2& =$!-( <*+*( ?!&$$6,. )\' $19 , #9=!1 <*=6 <6;66#",0x32);
if (iVar1 != 0) {
puts("Nope");
                /* WARNING: Subroutine does not return */
exit(1);
}
win();

The xor_ function is implemented as follows.

It checks that the input string length matches that of anextremelycomplicatedkeythatisdefinitelyuselessss, then XORs each input character with the corresponding key character.

void xor_(long param_1)
{
  size_t sVar1;
  int local_24;
  
  local_24 = 0;
  while( true ) {
    sVar1 = strlen("anextremelycomplicatedkeythatisdefinitelyuselessss");
    if (sVar1 <= (ulong)(long)local_24) break;
    *(byte *)(param_1 + local_24) =
         *(byte *)(param_1 + local_24) ^
         "anextremelycomplicatedkeythatisdefinitelyuselessss"[local_24];
    local_24 = local_24 + 1;
  }
  return;
}

So XOR-ing anextremelycomplicatedkeythatisdefinitelyuselessss with 2& =$!-( <*+*( ?!&$$6,. )\' $19 , #9=!1 <*=6 <6;66# reveals that the magic word needed is SHEEPSHEEPSHEEPSHEEPSHEEP(OAPXJDIFJWTUTLE_NSLYEHEEB.

image-20230429175845337

Sending this to the challenge server obtained the flag.

Bananas (Rev)

The challenge binary Elixir.Bananas.beam is clearly compiled from Elixir.

I used the following tool to decompile it.

Reference: GitHub - michalmuskala/decompile

To use the tool you need Elixir installed and the mix command available.

Multiple output formats are available:

mix decompile ElixirModule --to expanded
mix decompile ElixirModule --to erlang
mix decompile ElixirModule --to asm
mix decompile ElixirModule --to core

expanded was the most readable, so I used that. The output is:

defmodule Bananas do
  defp to_integer([num, string]) do
    [:erlang.binary_to_integer(num), string]
  end

  defp to_integer(list) do
    list
  end

  defp print_flag(false) do
    IO.puts("Nope")
  end

  defp print_flag(true) do
    IO.puts(File.read!("flag.txt"))
  end

  def main(args) do
    print_flag(check(convert_input(IO.gets("How many bananas do I have?\n"))))
  end

  def main() do
    super([])
  end

  defp convert_input(string) do
    to_integer(String.split(String.trim(string)))
  end

  defp check([num, "bananas"]) do
    :erlang.==(:erlang.-(:erlang.*(:erlang.+(num, 5), 9), 1), 971)
  end

  defp check(_asdf) do
    false
  end
end

This code can be run directly (with minor modifications) on an online service such as AtCoder, which made testing very easy.

As you can see, only input in the format <number> bananas proceeds to validation by the check function.

The equation (number + 5) * 9 - 1 = 971 gives 103 as the correct answer.

Sending 103 bananas to the challenge server obtained the flag.

moon (Rev)

Reading the problem statement, the correct sequence of inputs to the binary is the flag in ASCII.

To the moon! The correct sequence of inputs is the flag in ASCII.

Analyzing the binary, there are 1293 functions defined — func0 through func1292.

image-20230429182044810

Each function has the same structure: it adds a value to 8-byte slots in a data region called check, which has 1293 slots of 8 bytes each.

image-20230429182346601

The validation logic compares needed against check.

image-20230429183003795

needed is also 8 bytes × 1293 entries and has values hardcoded into it.

Input is accepted as up to 1293 digits; each digit’s value determines how many times the corresponding function is called.

(If the input is 123…, func0 is called once, func1 twice, func2 three times, and so on.)

The task is to find an input that makes check exactly equal to needed after calling all functions the right number of times.

I identified all of this but couldn’t come up with a solver algorithm — thinking too rigidly.

In hindsight, if you let x, y, z, … denote the number of times each function runs, you get a system of 1293 equations in 1293 unknowns — solvable by simultaneous equations.

It’s easy to get stuck in rigid thinking, so I need to be more flexible.

I used the following reference for the final solver.

Reference: ångstromCTF 2023 - Reverse Engineering Writeups - FazeCT Blogs

First, use pwntools to extract the 1293 hardcoded needed values.

from pwn import *
e = ELF('./moon',checksec=False)

needed = []
for i in range(1293):
    data = e.read(e.sym['needed']+i*8, 8)
    data = int.from_bytes(data,'little')
    needed.append(data)

print(needed)

Then extract the addend from each func function to build the system of equations.

This step uses pwntools’ ELF module and takes a fair amount of time.

from sage.all import *

mat = Matrix(check)
mat = mat.T
needed = vector(needed)
print(mat.solve_right(needed))

Solving the system with the extracted values yields the flag.

Physics HW (Misc)

Analyzing the provided pcap file, I found data that appeared to be a corrupted ZIP file being transmitted.

image-20230422205325945

Exporting the file from Wireshark and extracting it with 7-Zip let me examine the contents.

The flag was recovered from the extracted file.

Summary

I was close to solving moon but couldn’t come up with the solver because I was thinking too rigidly.

I’ve been saying this for about 3 years now — I really need to keep practicing.