>/posts/Brød & Co. $

Estimated reading time: 36 minutes


Brød & Co.

Category: Mobile

Difficulty: Hard

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


First Part

Look at the files

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

So now reverse it!

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

/writeups/Brunnerctf 2025/Brød & Co./solve/apktool MasterBaker.apk

Navigate to the /lib/x86_64/

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 strings we are looking for, now search for cross-references, and there is logic I've found:


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 (0x61 0f 1c 1b da 2d e7 2d f0 24 58 ab 74 02 53 db).

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), which prevents trivial timing-based attacks. 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.

Maths

Let:

$$ K = \text{"THIS_CAKE_PLEASE"} $$

$$ K_2 = \text{"MAKE_IT_FREE_PLZ"} $$

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

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

Validation succeeds if:

$$ E_K(P) \oplus K_2 = K_3 $$

where \(E_{K}\) is AES-128-ECB encryption and \(\oplus\) is bytewise XOR.

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

$$ \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 = K_2 \oplus K_3 $$

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

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

Here \(D_{K}\) is AES-128-ECB decryption, and \(\operatorname{unpad}\) removes the 'PKCS#7-like' padding if the last byte value \(p\) appears exactly \(p\) times.

Here's code used for this:

#!/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"

And secret code is:

FREE_BRUNSVIGER_

Let's check it inside the app:

The flag!

5

And here it is:

brunner{wh0_kn3w_dart_c0u1d_h4nd13_C?!}

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()\). 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()\) 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.

const MOD = "libnative.so";

function whenLoaded(name, cb) {
  const m = Process.findModuleByName(name);
  if (m) return cb(m);
  const h = Module.findExportByName(null, "android_dlopen_ext") || Module.findExportByName(null, "dlopen");
  Interceptor.attach(h, { onLeave() { const mm = Process.findModuleByName(name); if (mm) { try { this.detach(); } catch(_){} cb(mm); } } });
}

whenLoaded(MOD, (m) => {
  if (Process.arch !== "x64") return;

  const proc =
    Module.findExportByName(m.name, "process_data_complete") ||
    Module.enumerateSymbolsSync(m.name).find(s => s.type === "function" && s.name.includes("process_data_complete"))?.address;
  if (!proc) return;

  function callees(p, span) {
    const s = Math.min(span, 0x2000), buf = Memory.readByteArray(p, s), u = new Uint8Array(buf), d = new DataView(buf), out = new Set();
    for (let i = 0; i + 4 < u.length; i++) {
      if (u[i] === 0xE8) {
        const tgt = p.add(i + 5 + d.getInt32(i + 1, true));
        if (tgt.compare(m.base) >= 0 && tgt.compare(m.base.add(m.size)) < 0) out.add(tgt.toString());
      }
      if (u[i] === 0xC3) break;
    }
    return Array.from(out).map(s => ptr(s));
  }

  let p7 = null, p5 = null;
  Interceptor.attach(proc, {
    onEnter(args) { const a = this.context.rdi; p7 = a.add(7); p5 = a.add(5); },
    onLeave() { p7 = p5 = null; }
  });

  for (const f of callees(proc, 0x1000)) {
    Interceptor.attach(f, {
      onEnter() { const a0 = this.context.rdi; this.hit = (p7 && a0.compare(p7) === 0) || (p5 && a0.compare(p5) === 0); },
      onLeave(rv) { if (this.hit) rv.replace(1); }
    });
  }
});

Just look at this:

6