🧠 Analysis Summary

The application implements a custom bytecode interpreter. This interpreter loads and executes critical application logic from an obfuscated asset file. The challenge involves reverse engineering this custom bytecode and its interpreter to understand the application’s true functionality.

Decompiled code are modified to improve readability and understanding. The original code may have different variable names or structures.

🔍 Vulnerability/Key Concepts

Custom Bytecode Interpreter Obfuscation

The Pedometer application uses a custom bytecode interpreter to execute its core logic. Analysis of the MainActivity and u1.c (C0976c) classes reveals that the application reads an unknown bytecode format from an asset file named a. This design acts as a form of obfuscation, making static analysis challenging as the actual execution flow is defined by this custom bytecode, rather than directly visible Java or Kotlin code. The u1.c class is responsible for reading the bytecode from the asset file, managing a stack, and providing a method (m2264a()) to pop integer values.

// From u1.c (C0976c) constructor
public C0976c(MainActivity mainActivity) {
    AbstractC1073e.m2493y(mainActivity, "main");
    this.mainAct = mainActivity;
    InputStream open = mainActivity.getAssets().open("a"); // Reading bytecode from asset 'a'
    AbstractC1073e.m2491x(open, "main.assets.open(\"a\")");
    this.f4060b = open; // This is the bytecodeInput stream
    this.cStack = new Stack(); // This is the vm.stack
}

// From u1.c (C0976c) method to pop values
public final int m2264a() { 
    Stack stack = this.cStack;
    Integer num = (Integer) stack.peek();
    stack.pop();
    AbstractC1073e.m2491x(num, "value");
    return num.intValue();
}

Dynamic Bytecode Execution via SensorEventListener

The MainActivity sets up sensor event handling. It calls the m997n() method, which gets a SensorManager and registers a listener named C0974a. This listener contains the main logic. Its onSensorChanged method works like a virtual machine.

Each time the sensor detects a change, the method reads a byte from the asset file a, XORs it with a changing key, and decodes it into an opcode. The opcode then refered from EnumC0975b enum opcodes. The interpreter supports stack operations (push, pop), math (add, subtract), jumps, and battery checks.

// Deobfuscated SensorEventListener (p070u1.a.onSensorChanged) logic
@Override
public void onSensorChanged(SensorEvent event) {
    long now = Calendar.getInstance().getTimeInMillis();
    if (now - lastEventTime <= 300) return; // Rate limit sensor events

    lastEventTime = now;
    // ... (step counting logic omitted for brevity) ...

    if (vm != null) { // vm refers to the C0976c instance, holding bytecode input stream and stack
        InputStream in = vm.bytecodeInput; // Input stream from 'a' file
        if (in.available() == 0) return;

        int encryptedOpcode = in.read(); // Read the next encrypted byte
        int opcode = encryptedOpcode ^ vm.key; // Decrypt opcode using current XOR key

        EnumC0975b instruction = EnumC0975b.fromOpcode(opcode); // Map to a known instruction
        Stack<Integer> stack = vm.stack;
        MainActivity act = vm.mainActivity;

        switch (instruction) { // Execute instruction based on the decoded opcode
            case STOP: // opcode 0
                in.skip(in.available()); // Skip remaining bytes, effectively stopping execution
                break;
            case PUSH: // opcode 1
                int val = in.read() ^ vm.key; // Read next byte, decrypt it, and push to stack
                stack.push(val);
                break;
            case POP: // opcode 2
                stack.pop();
                break;
            case ADD: // opcode 3
                stack.push(stack.pop() + stack.pop());
                break;
            case XOR: // opcode 12
                vm.key = stack.pop() ^ stack.pop(); // Pop two, XOR them, and update the global XOR key (vm.key)
                break;
            case IF: // opcode 13 (Conditional jump)
                if (stack.pop() == 1) { // If condition (top of stack) is true
                    int offset = vm.nextByte(); // Read next byte (which is the jump target/offset)
                    vm.key = offset; // Update XOR key with the offset
                } else {
                    vm.nextByte(); // If condition is false, consume the offset byte but don't jump
                }
                break;
            case JMP: // opcode 14 (Unconditional jump)
                int offset = vm.nextByte(); // Read next byte (which is the jump target/offset)
                vm.key = offset; // Update XOR key with the offset
                break;
            case CHRG: // opcode 15
                boolean isCharging = act.getBatteryStatus();
                stack.push(isCharging ? 1 : 0);
                break;
            case FLAG: // opcode 20
                char[] flagChars = new char[22]; // Allocate for 22 characters
                for (int i = 0; i < 22; i++) {
                    flagChars[i] = (char) vm.nextByte(); // Read 22 bytes, decrypt, and convert to char
                }
                String flag = new String(flagChars); // Create the flag string
                TextView flagView = act.findViewById(R.id.flagText);
                flagView.setText(flag); // Display the flag
                break;
            // ... (other cases for SUB, MUL, DIV, MOD, EQ, LT, GT, NOT, AIRPLN, INTRNT, ENC, DEC) ...
        }
    } else {
        throw new IllegalStateException("stepReader not initialized");
    }
}

⚙️ Exploitation/Methodology

In order to solving this challenge we should involves a step-by-step reverse engineering process. This process extracts the obfuscated bytecode. It then simulates the custom virtual machine’s execution.

Step 1: asset/a File Extraction

First, we need to extract the a file from the Android application. This file contains the custom bytecode that the interpreter executes. We can use tools like apktool or jadx to decompile the APK and access its assets.

# Use apktool to decompile the APK
apktool d pedometer.apk -o pedometer_decompiled
# Navigate to the decompiled assets directory
cd pedometer_decompiled/assets
# The 'a' file should be present here
# Alternatively, use jadx to view the APK structure
jadx -d pedometer_jadx pedometer.apk
# Navigate to the jadx output directory
cd pedometer_jadx/assets
# The 'a' file should be present here as well
hexdump decompiled/assets/a 
0000000 0101 0001 0101 0101 0101 0101 0101 0101
0000010 20f0 0040 20f1 0040 20f2 0040 20f0 0040
0000020 2a01 2bf3 2b60 1b1d 567c 227c 754c 7538
0000030 4508 3131 4131 7101 7189 41fa 0972 5672
0000040 5e42 5e96 6ede 4d49 2849 6479 64dd 54a9
0000050 7575 2a75 5e45 5eca 6eb9 c872 8372 4a42
0000060 4ad5 7aa7 1173 2573 3543 3591 05fc 0d6c
0000070 526c 5e5c 5e61 6e39 7259 0959 7a69 7a53
0000080 4a11 5943 0d43 5573 55bd 65f5 ffbc 0000

Step 3: Bytecode Interpreter Simulation

We can create a solver that emulates the custom bytecode interpreter used by the Android app, we can use LLM for creating this solver. This solver follows the logic found in the original u1.c class and the onSensorChanged method. It allows us to run the bytecode outside the app and extract the hidden string when the FLAG instruction is reached.

But first, we need to extract the opcode value from the enum EnumC0975b. Since the code are taking the opcode from the enum using .values() method, we can use frida to dump the enum values and their corresponding opcodes

Java.perform(function() {
    let EnumC0975b = Java.use("u1.b");
    let enumValues = EnumC0975b.values();
    console.log("EnumC0975b values", enumValues);

    for (let i = 0; i < enumValues.length; i++) {
        const e = enumValues[i];
        console.log(
            `Ordinal: ${e.ordinal()}, Name: ${e.name()}, f4058a: ${e.a.value}`
        );
    }


});

After we get the opcode values, we can implement the bytecode solver in Python that simulates the stack-based execution.

#!/usr/bin/env python3

def solve_bytecode(hex_string):
    # Convert hex to bytes
    bytecode = bytes.fromhex(hex_string.replace(' ', '').replace('\n', ''))
    
    # Initialize state
    stack = []
    position = 0
    xor_key = 0
    
    print(f"Processing {len(bytecode)} bytes...")
    
    while position < len(bytecode):
        # Read and decode current byte
        raw_byte = bytecode[position]
        opcode = raw_byte ^ xor_key
        position += 1
        
        # Execute instruction
        '''
        From the frida dump, we have the following opcodes:
        EnumC0975b values STOP,PUSH,POP,ADD,SUB,MUL,DIV,MOD,EQ,LT,GT,NOT,XOR,IF,JMP,CHRG,AIRPLN,INTRNT,ENC,DEC,FLAG
        Ordinal: 0, Name: STOP, f4058a: 0
        Ordinal: 1, Name: PUSH, f4058a: 1
        Ordinal: 2, Name: POP, f4058a: 2
        Ordinal: 3, Name: ADD, f4058a: 16
        Ordinal: 4, Name: SUB, f4058a: 17
        Ordinal: 5, Name: MUL, f4058a: 18
        Ordinal: 6, Name: DIV, f4058a: 19
        Ordinal: 7, Name: MOD, f4058a: 20
        Ordinal: 8, Name: EQ, f4058a: 32
        Ordinal: 9, Name: LT, f4058a: 33
        Ordinal: 10, Name: GT, f4058a: 34
        Ordinal: 11, Name: NOT, f4058a: 48
        Ordinal: 12, Name: XOR, f4058a: 49
        Ordinal: 13, Name: IF, f4058a: 64
        Ordinal: 14, Name: JMP, f4058a: 65
        Ordinal: 15, Name: CHRG, f4058a: 240
        Ordinal: 16, Name: AIRPLN, f4058a: 241
        Ordinal: 17, Name: INTRNT, f4058a: 242
        Ordinal: 18, Name: ENC, f4058a: 243
        Ordinal: 19, Name: DEC, f4058a: 244
        Ordinal: 20, Name: FLAG, f4058a: 255
        '''
        if opcode == 0:  # STOP
            continue
            
        elif opcode == 1:  # PUSH
            if position >= len(bytecode):
                break
            value = bytecode[position]
            stack.append(value)
            position += 1
            
        elif opcode == 2:  # POP
            if stack:
                stack.pop()
                
        elif opcode == 16:  # ADD
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                stack.append(a + b)
                
        elif opcode == 17:  # SUB
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                stack.append(a - b)
                
        elif opcode == 18:  # MUL
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                stack.append(a * b)
                
        elif opcode == 19:  # DIV
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                stack.append(a // b if b != 0 else 0)
                
        elif opcode == 20:  # MOD
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                stack.append(a % b if b != 0 else 0)
                
        elif opcode == 32:  # EQ
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                stack.append(1 if a == b else 0)
                
        elif opcode == 33:  # LT
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                stack.append(1 if a < b else 0)
                
        elif opcode == 34:  # GT
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                stack.append(1 if a > b else 0)
                
        elif opcode == 48:  # NOT
            if stack:
                a = stack.pop()
                stack.append(1 if a == 0 else 0)
                
        elif opcode == 49:  # XOR
            if len(stack) >= 2:
                b, a = stack.pop(), stack.pop()
                result = a ^ b
                stack.append(result)
                xor_key = result & 0xFF  # Update XOR key
                
        elif opcode == 64:  # IF (conditional skip)
            if stack:
                condition = stack.pop()
                if condition == 0 and position < len(bytecode):
                    position += 1  # Skip next byte
                    
        elif opcode == 65:  # JMP (unconditional skip)
            if position < len(bytecode):
                position += 1  # Skip next byte
                
        elif opcode == 240:  # CHRG (charging status)
            stack.append(1)  # Assume charging
            
        elif opcode == 241:  # AIRPLN (airplane mode)
            stack.append(0)  # Assume airplane mode off
            
        elif opcode == 242:  # INTRNT (internet)
            stack.append(1)  # Assume connected
            
        elif opcode == 243 or opcode == 244:  # ENC/DEC
            if stack:
                xor_key = stack.pop() & 0xFF
                
        elif opcode == 255:  # FLAG (extract string)
            if len(stack) >= 21:
                # Get 21 characters from stack
                chars = []
                for _ in range(21):
                    chars.append(chr(stack.pop() & 0xFF))
                
                # Reverse since stack is LIFO
                result = ''.join(reversed(chars))
                print(f"Found target string: '{result}'")
                return result
            else:
                print(f"FLAG: Not enough stack elements ({len(stack)}/21)")
                break

    return stack

if __name__ == "__main__":
    hex = """
    0101 0100 0101 0101 0101 0101 0101 0101
    f020 4000 f120 4000 f220 4000 f020 4000
    012a f32b 602b 1d1b 7c56 7c22 4c75 3875
    0845 3131 3141 0171 8971 fa41 7209 7256
    425e 965e de6e 494d 4928 7964 dd64 a954
    7575 752a 455e ca5e b96e 72c8 7283 424a
    d54a a77a 7311 7325 4335 9135 fc05 6c0d
    6c52 5c5e 615e 396e 5972 5909 697a 537a
    114a 4359 430d 7355 bd55 f565 bcff 00
    """
    
    
    result = solve_bytecode(hex)
    print(f"\nResult: {result}")

And we got the flag 😁

✅ Conclusion

This challenge demonstrates how Android apps can embed custom VMs and obfuscation techniques to hinder analysis. By dissecting the interpreter logic and simulating the bytecode execution, we successfully reverse engineered the hidden logic and extracted the flag.