ARMssembly 3 picoCTF 2021 Solution

Published: April 2, 2026

Description

What integer does this program print with arguments 2541761492 and 4030728319? Analyze the ARM assembly to compute the output.

Download the ARM assembly source file.

bash
wget https://mercury.picoctf.net/static/.../chall_3.S
bash
cat chall_3.S

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Read the source: a tiny dispatch into func1 and func2
    Observation
    I noticed the challenge provides an ARM assembly source file and asks for the printed integer given a specific argument, which suggested reading the .S file to understand how func1 and func2 interact before attempting any computation.
    main calls atoi(argv[1]) once, passes the result to func1, and prints whatever func1 returns. The second argument the prompt mentions is unused. func1 is the loop you need to translate, func2 is a one-liner.
    bash
    cat chall_3.S | sed -n '/^func1:/,/.size\s*func1/p'
    bash
    cat chall_3.S | sed -n '/^func2:/,/.size\s*func2/p'

    Expected output

    decimal=42 hex=0x0000002a
    What didn't work first

    Tried: Running the .S file directly with bash or as a script to see what it prints

    The file is ARM assembly source, not x86 machine code or a shell script. Running it on an x86 host produces 'Exec format error' or garbled output. You need to either cross-compile with aarch64-linux-gnu-gcc and emulate with qemu-aarch64, or translate the logic manually into Python.

    Tried: Grepping for the return value or a print statement to read the answer directly from the source

    The .S file prints a runtime-computed result via printf, not a hardcoded string. Grepping for 'Result' or 'answer' only finds the format string literal. The actual number comes from func1's computation on the argv[1] input, so you must trace the logic rather than search for a static value.

    Learn more

    The relevant excerpt of func1:

    func1:
        str  w0, [x29, 28]      ; n = arg
        str  wzr, [x29, 44]     ; result = 0
        b    .L2
    .L4:
        ldr  w0, [x29, 28]
        and  w0, w0, 1          ; if (n & 1)
        cbz  w0, .L3
        ldr  w0, [x29, 44]
        bl   func2              ;   result = func2(result)
        str  w0, [x29, 44]
    .L3:
        ldr  w0, [x29, 28]
        lsr  w0, w0, 1          ; n >>= 1
        str  w0, [x29, 28]
    .L2:
        ldr  w0, [x29, 28]
        cbnz w0, .L4            ; while (n != 0)
        ldr  w0, [x29, 44]
        ret

    func2 is just x + 3.

    Translated: walk the bits of n from low to high. Each set bit contributes one call to func2(result), which adds 3. So the final return value is popcount(n) * 3.

    See the Ghidra reverse engineering guide if you want the GUI walkthrough instead.

  2. Step 2
    Compute the answer in one Python line
    Observation
    I noticed that func1's loop tests bit 0 and shifts right on each iteration while calling func2 for each set bit, and func2 simply adds 3; this identified the entire computation as popcount(n) * 3, which can be evaluated instantly in Python rather than emulating the assembly.
    The result is bin(arg).count('1') * 3 with 32-bit masking. Pass your instance's argument and read the printed hex.
    python
    python3 -c "arg = 2541761492; r = (bin(arg & 0xFFFFFFFF).count('1') * 3) & 0xFFFFFFFF; print(f'decimal={r} hex={r:#010x}')"
    bash
    # arg=2541761492 -> 42  (0x0000002a)
    bash
    # arg=4030728319 -> 39  (0x00000027)  (in case your instance uses this one)
    What didn't work first

    Tried: Counting set bits in the input and multiplying by 1 instead of 3 because func2 was not checked

    func1 calls func2 on each set bit, and func2 returns x + 3, not x + 1. Skipping the func2 analysis gives popcount(n) instead of popcount(n) * 3. For arg=2541761492 this produces 14 instead of 42, which is a valid-looking small number but maps to the wrong hex string and a wrong flag.

    Tried: Submitting the decimal result (42) directly as the flag instead of converting to an 8-digit lowercase hex string

    The program prints the result formatted as a hex string, not a decimal. The flag is picoCTF{} wrapping the 8-character zero-padded lowercase hex form (e.g. 0000002a), not the decimal integer 42. Submitting the raw decimal is the most common last-step mistake on ARMssembly challenges.

    Learn more

    Why 32-bit masking matters here: ARMv8 w0 is a 32-bit register, and add wraps modulo 232. popcount * 3 for any 32-bit input maxes at 32 * 3 = 96, so the wrap doesn't kick in. The mask is defensive boilerplate; copy it because you'll need it on the next ARMssembly challenge where wrapping does matter.

    Alternative: just emulate the binary. If hand-translating feels brittle, build and run with QEMU: aarch64-linux-gnu-gcc -static -o chall_3 chall_3.S && qemu-aarch64 ./chall_3 2541761492. The output line Result: N is your answer; convert N to 8-hex-digit lowercase for the flag.

    See Python for CTF for more reverse-engineering one-liners and Pwntools for CTF for QEMU automation.

Flag

Reveal flag

picoCTF{0000002a}

Per-instance challenge. The reference computation shows arg=2541761492 -> popcount=14 -> 14*3=42 -> hex 0000002a. Different picoCTF instances get different arguments and thus different flags.

Key takeaway

Bit manipulation patterns, especially testing and consuming bits one at a time with 'and reg, 1' followed by 'lsr reg, 1', appear constantly in assembly across all architectures. Recognizing this as a popcount (population count, the number of set bits) loop lets you replace the entire function with a one-liner. The same pattern drives CRC computation, hash functions, and cryptographic primitives; being able to lift assembly loops into high-level mathematical expressions is the core skill that separates fast reverse engineering from slow disassembly tracing.

Related reading

Want more picoCTF 2021 writeups?

Useful tools for Reverse Engineering

What to try next