team-logo
Published on

Brød & Co. — Mobile (Hard) Writeup

Authors

Brød & Co.

Category: Mobile
Difficulty: Hard
Challenge Author: KyootyBella
Tags: mobile, reverse, app

Brød & Co. just released their new ordering app, but their prices are a bit high. If only I had a coupon code...

Note: The app sometimes crashes when clicking "Place Order". If this happens, try again or try another approach.


Handout files

mobile_brod-and-co.zip


Recon / First Look

After downloading and unzipping the archive there’s only one APK inside, so I launched it on an Android emulator and also opened the APK in JADX-GUI for static analysis.

1

Not much to see in the UI at first glance.

2

There’s a discount code field that’s likely the target. Let’s peek inside the app. In AndroidManifest.xml viewed in JADX you can spot Flutter-specific metadata (flutterEmbedding = 2). That means UI strings and most logic won't be in res/values/strings.xml or Java/Kotlin; Flutter compiles Dart to native code in libapp.so. Searching resources for things like “coupon”, “invalid”, etc. returns nothing as expected for Flutter.

3

Native Reversing

Time to check native libraries. I used apktool to disassemble the APK:

apktool d MasterBaker.apk

Navigate to the /lib/x86_64/ directory:

Mode              LastWriteTime           Length     Name
----              -------------           ------     ----
-a----            2025-08-23 19:19        4,588,448  libapp.so
-a----            2025-08-23 19:19       12,383,280  libflutter.so
-a----            2025-08-23 19:19           18,288  libnative.so

I picked the most interesting one, libnative.so, and dumped its strings:

4

That's bingo — we found the strings we are looking for. Now search for cross-references, and here is the logic I found (decompiled and lightly renamed for clarity):

char * process_data_complete(char *input_string)
{
  int ret_val;
  undefined8 uVar1;
  long lVar2;
  char *allocated_buffer;
  char *local_48;

  ret_val = strncmp(input_string,"COUPON:",7);
  if (ret_val == 0) {
    ret_val = processingInputFunc(input_string + 7);
    if (ret_val == 0) {
      local_48 = strdup("INVALID_COUPON");
    }
    else {
      local_48 = strdup("VALID_COUPON");
    }
  }
  else {
    ret_val = strncmp(input_string,"FLAG:",5);
    if (ret_val == 0) {
      ret_val = processingInputFunc(input_string + 5);
      if (ret_val != 0) {
        uVar1 = decryptFlag();
        lVar2 = __strlen_chk(uVar1,0xffffffffffffffff);
        allocated_buffer = (char *)malloc(lVar2 + 0x10);
        if (allocated_buffer != (char *)0x0) {
          lVar2 = __strlen_chk(uVar1,0xffffffffffffffff);
          FUN_001019f0(allocated_buffer,0xffffffffffffffff,lVar2 + 0x10,"FLAG|%s",uVar1);
          return allocated_buffer;
        }
      }
      local_48 = strdup("FLAG|INVALID_COUPON");
    }
    else {
      ret_val = FUN_00102570(input_string);
      if (ret_val != 0) {
        uVar1 = decryptFlag();
        lVar2 = __strlen_chk(uVar1,0xffffffffffffffff);
        allocated_buffer = (char *)malloc(lVar2 + 0x10);
        if (allocated_buffer != (char *)0x0) {
          lVar2 = __strlen_chk(uVar1,0xffffffffffffffff);
          FUN_001019f0(allocated_buffer,0xffffffffffffffff,lVar2 + 0x10,"OK|%s",uVar1);
          return allocated_buffer;
        }
      }
      local_48 = (char *)FUN_00101c50(input_string);
    }
  }
  return local_48;
}

Note: function and variable names below are the ones I assigned during analysis.

The key dispatcher calls processingInputFunc(input + n). The offset n skips past the command prefix: n = 7 for "COUPON:" and n = 5 for "FLAG:". In other words, for input like COUPON:ABC the validator sees just ABC.

bool processingInputFunc(long param_1)
{
  byte ret_val;
  bool bool_ret_val;
  int local_index3;
  int local_index2;
  byte AES_Output [24];
  ulong local_index;
  char pad_len;
  ulong param_1_len;
  char blockBuffer [24];
  long local_40;
  undefined8 local_30;
  long local_28;
  ulong local_20;
  long local_18;
  undefined8 local_10;
  char *local_8;

  if (param_1 == 0) {
    bool_ret_val = false;
  }
  else {
    local_40 = param_1;
    memset(blockBuffer,0,0x10);
    local_28 = local_40;
    local_30 = 0xffffffffffffffff;
    param_1_len = __strlen_chk(local_40,0xffffffffffffffff);
    if (0x10 < param_1_len) {
      param_1_len = 0x10;
    }
    local_8 = blockBuffer;
    local_10 = 0x10;
    local_18 = local_40;
    local_20 = param_1_len;
    __memcpy_chk(local_8,local_40,param_1_len,0x10);
    pad_len = 0x10 - (char)param_1_len;
    for (local_index = param_1_len; local_index < 0x10; local_index = local_index + 1) {
      blockBuffer[local_index] = pad_len;
    }
    AESEncryptBlock(blockBuffer,AES_Output,&AES_Key);
    for (local_index2 = 0; local_index2 < 0x10; local_index2 = local_index2 + 1) {
      AES_Output[local_index2] = AES_Output[local_index2] ^ (&Mask)[local_index2];
    }
    ret_val = 0;
    for (local_index3 = 0; local_index3 < 0x10; local_index3 = local_index3 + 1) {
      ret_val = AES_Output[local_index3] ^ (&ExpectedVal)[local_index3] | ret_val;
    }
    bool_ret_val = ret_val == 0;
  }
  return bool_ret_val;
}

Inside processingInputFunc(...) the code:

  • takes up to 16 bytes of the string,
  • applies PKCS#7-like padding to 16 bytes,
  • encrypts that 16B block with AES-128-ECB using the key THIS_CAKE_PLEASE,
  • XORs the ciphertext with the 16B mask K2 = "MAKE_IT_FREE_PLZ",
  • compares the result against the 16B constant K3 = 61 0f 1c 1b da 2d e7 2d f0 24 58 ab 74 02 53 db (hex).

One thing worth noting is the final loop that compares the two 16-byte buffers: it XORs each byte with the expected value and bitwise-ORs the result into an accumulator. Because the loop always iterates over all 16 bytes with no early exit, this acts as a constant-time equality check (for fixed-length inputs). It doesn't matter much in this challenge — everything needed is client-side and the scheme is reversible — but it's a useful pattern to recognize; if code uses memcmp or breaks on first mismatch, a timing attack may be possible.


Math

Let:

K="THIS_CAKE_PLEASE"K = \text{"THIS\_CAKE\_PLEASE"}

K2="MAKE_IT_FREE_PLZ"K_2 = \text{"MAKE\_IT\_FREE\_PLZ"}

K3=(fixed 16-byte constant)K_3 = \text{(fixed 16-byte constant)}

P=pad16(input[0:16])P = \operatorname{pad}_{16}\big(\text{input}[0{:}16]\big)

Validation succeeds if:

EK(P)K2=K3E_K(P) \oplus K_2 = K_3

where EKE_K is AES-128-ECB encryption and \oplus is bytewise XOR.

The padding is PKCS#7-like for a single 16-byte block:

pad16(m)=m  pppp=16m bytes,0m16\operatorname{pad}_{16}(m) = m\ \Vert\ \underbrace{pp\ldots p}_{p=16-|m|\ \text{bytes}},\quad 0\le |m|\le 16

Equivalently, to recover a valid coupon without brute force:

C=K2K3C = K_2 \oplus K_3

P=DK(C)P = D_{K}(C)

coupon=unpad(P)\text{coupon} = \operatorname{unpad}(P)

Here DKD_K is AES-128-ECB decryption, and unpad\operatorname{unpad} removes the PKCS#7-like padding if the last byte value pp appears exactly pp times.


Exploit Code (Ruby)

#!/usr/bin/env ruby
# AES-128-ECB + XOR K2/K3 → recover coupon

require "openssl"

def xor_bytes(a, b)
  a.bytes.zip(b.bytes).map { |x, y| (x ^ y) & 0xFF }.pack("C*")
end

def unpad_pkcs7(s)
  pad = s.bytes[-1]
  if pad && pad >= 1 && pad <= 16 && s.bytes.last(pad) == [pad] * pad
    s.byteslice(0, s.bytesize - pad)
  else
    s
  end
end

def pad16(s)
  s = s.byteslice(0, 16)
  if s.bytesize < 16
    pad = 16 - s.bytesize
    s + (pad.chr * pad)
  else
    s
  end
end

# --- Constants from .rodata ---
key = "THIS_CAKE_PLEASE".b                                # 16 bytes
k2  = ["4d414b455f49545f465245455f504c5a"].pack("H*")     # "MAKE_IT_FREE_PLZ"
k3  = ["610f1c1bda2de72df02458ab740253db"].pack("H*")

# C = K2 XOR K3
c = xor_bytes(k2, k3)

# AES-128-ECB decrypt (no OpenSSL padding)
dec = OpenSSL::Cipher.new("AES-128-ECB")
dec.decrypt
dec.padding = 0
dec.key = key
plain = dec.update(c) + dec.final  # 16 bytes

coupon_bytes = unpad_pkcs7(plain)

coupon =
  if coupon_bytes.bytes.all? { |b| b >= 32 && b <= 126 }
    coupon_bytes # printable ASCII
  else
    coupon_bytes.unpack1("H*") # fallback: hex
  end

puts "Coupon: #{coupon}"

# (optional) verification like in the binary
blk = pad16(coupon_bytes)
enc = OpenSSL::Cipher.new("AES-128-ECB")
enc.encrypt
enc.padding = 0
enc.key = key
check = xor_bytes(enc.update(blk) + enc.final, k2)
raise "Validation failed – check the data." unless check == k3
puts "OK: verification passed"

Recovered coupon:

FREE_BRUNSVIGER_


In-App Verification

5

Flag:

brunner{wh0_kn3w_dart_c0u1d_h4nd13_C?!}


Notes

  • The validator truncates to a single 16-byte block; longer inputs beyond 16 chars don’t matter.
  • Constant-time comparison prevents trivial timing attacks but doesn’t add security here since everything is local and reversible.
  • Flutter UI logic lives in native libapp.so, but the interesting validation was kept in a tiny libnative.so — handy for focus during reversing.

Round 2

That was pretty neat, wasn't it? We reversed the validator and derived a valid coupon. But take a closer look at processingInputFunc()processingInputFunc(). It performs AES-128-ECB on a padded 16-byte block, XORs the result, and then does a constant-time comparison against a 16-byte constant. Crucially, it doesn't build the flag; it only returns a boolean that controls whether process_data_complete()process\_data\_complete() will call the native flag generator.

In other words, simplified:

bool processingInputFunc(const char *s) {
    // ... crypto + constant-time compare ...
    return /* true if coupon is valid, false otherwise */;
}

And all we really need is to make it return true (non-zero) so any coupon passes. I realized this while writing the write-up: the simplest attack is to hook the validator and force its return value to true. With Frida, a minimal hook can flip the function's return to 1 so the app treats every input as valid.

Just look at this:

6