- Published on
Brød & Co. — Mobile (Hard) Writeup
- Authors
- Name
- BarryPL
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
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.

Not much to see in the UI at first glance.

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.

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:

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:
Validation succeeds if:
where is AES-128-ECB encryption and is bytewise XOR.
The padding is PKCS#7-like for a single 16-byte block:
Equivalently, to recover a valid coupon without brute force:
Here is AES-128-ECB decryption, and removes the PKCS#7-like padding if the last byte value appears exactly 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

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 tinylibnative.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 . 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 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:
