ARMssembly 4 picoCTF 2021 Solution

Published: April 2, 2026

Description

What integer does this program print with argument 3251107833? Analyze the ARM assembly, which includes looping and conditional logic.

Download the ARM assembly source file.

bash
wget https://mercury.picoctf.net/static/.../chall_4.S
bash
cat chall_4.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
    Map the call graph: func1 picks one of two branches
    Observation
    I noticed the file defines multiple small functions (func1 through func8) each with branch mnemonics like bls and bhi, which suggested tracing the full call graph from main outward before attempting any arithmetic, since the branching structure determines which formula applies to the given input.
    main calls func1(atoi(argv[1])). func1 splits on whether the input is <=100 or >100. The other small functions (func3, func4, func5, func7, func8) are all leaves; func6 is dead code (never called from this path).
    bash
    grep -nE '^func[0-9]+:|cmp|bls|bhi|bl ' chall_4.S
    What didn't work first

    Tried: Treat the bls/bhi branch conditions as signed comparisons (like C's <= and > on int) when translating to Python.

    bls is 'branch if lower or same' using the unsigned carry/zero flags, and bhi is 'branch if higher' unsigned. Treating them as signed means inputs above 2^31 (like 3251107833) land in the wrong branch, producing a completely wrong answer. The fix is to mask the input to 32 bits with x &= 0xFFFFFFFF before every comparison.

    Tried: Skip tracing func4 and assume it performs arithmetic on its argument because it calls func1 internally.

    func4 does call func1(17) but immediately discards the return value and returns its own argument x untouched - making it an identity function. Assuming func4 returns func1(17) or some transform of x produces a wrong expression in the collapsed formula for the 100 < x <= 399 case.

    Learn more

    Branching summary, after walking each function:

    func7(x) = (x > 100) ? x : 7
    func8(x) = x + 2
    func5(x) = func8(x)            ; = x + 2
    func4(x) = call func1(17), discard, return x   ; identity
    func3(x) = func7(x)            ; = (x > 100) ? x : 7
    
    func2(x):
        if (unsigned) x > 499:  return func5(x + 13)   ; = x + 15
        else:                   return func4(x - 86)   ; = x - 86
    
    func1(x):
        if (unsigned) x <= 100: return func3(x)        ; = 7
        else:                   return func2(x + 100)

    Note the unsigned compares. The branches use bls (branch if lower-or-same, unsigned) and bhi (branch if higher, unsigned), so a Python translation must compare with the input masked to 32 bits, not as a signed int. Inputs above 231 are still positive in this world.

  2. Step 2
    Collapse the call graph into three cases
    Observation
    I noticed that each leaf function reduced to a simple arithmetic expression (identity, +2, or a constant), which suggested substituting them bottom-up into func1 to get a single piecewise formula that could be evaluated directly in Python for any input, including the 32-bit-large argument 3251107833.
    Once func4 is identity and func5 is +2, every path through func1 reduces to a piecewise linear formula on the input.
    python
    python3 - <<'EOF'
    def chall4(x):
        x &= 0xFFFFFFFF
        if x <= 100:
            return 7
        elif (x + 100) <= 499:    # 100 < x <= 399
            return (x + 100 - 86) & 0xFFFFFFFF   # = x + 14
        else:                     # x > 399 (after +100 overflow check, none here)
            return (x + 100 + 15) & 0xFFFFFFFF   # = x + 115
    
    for arg in [50, 200, 600, 3251107833]:
        r = chall4(arg)
        print(f"arg={arg:<12} -> {r} (hex {r:#010x})")
    EOF

    Expected output

    picoCTF{...}
    What didn't work first

    Tried: Run the Python solver without masking intermediate results to 32 bits, treating all arithmetic as unbounded Python integers.

    ARM registers are 32-bit and wrap on overflow. The input 3251107833 plus 115 overflows a 32-bit register, so the real program yields a wrapped result. Python's integers never overflow, so skipping the & 0xFFFFFFFF mask on the return value produces 3251107948 without wrapping - which happens to be correct here, but the approach breaks silently on inputs where the addition itself overflows 32 bits.

    Tried: Submit the decimal result 3251107948 directly as the flag without converting it to an 8-digit lowercase hex string.

    ARM assembly challenges on picoCTF ask for the printed register value, which the stub code outputs as a hex-formatted integer. The flag format requires the lowercase 8-hex-digit form (c1c7f86c) wrapped in picoCTF{}, not the decimal integer.

    Learn more

    Worked output:

    arg=50           -> 7          (hex 0x00000007)
    arg=200          -> 214        (hex 0x000000d6)
    arg=600          -> 715        (hex 0x000002cb)
    arg=3251107833   -> 3251107948 (hex 0xc1c7f86c)

    For arg=3251107833 the answer is 3251107948. Convert that to lowercase 8-hex-digit form (no 0x prefix) and wrap with picoCTF{...} for submission.

    Sanity check via QEMU if you doubt the trace: aarch64-linux-gnu-gcc -static -o chall_4 chall_4.S && qemu-aarch64 ./chall_4 3251107833 should print Result: 3251107948.

    See Python for CTF for the simulation idiom and Ghidra reverse engineering if you want a graphical view of the dispatch tree.

Flag

Reveal flag

picoCTF{c1c7f86c}

Per-instance challenge. The reference computation shows arg=3251107833 -> result=3251107948 -> hex c1c7f86c. Different picoCTF instances get different arguments and thus different flags.

Key takeaway

Reading ARM assembly means understanding how branch mnemonics like bls and bhi encode signed versus unsigned comparisons, since the same bit pattern can represent very different values depending on interpretation. Translating a call graph to a piecewise Python model lets you run arbitrary inputs instantly without hardware, a technique that generalizes to analyzing firmware, embedded controllers, and compiled binary challenges on any ISA. The unsigned-compare pitfall, where values above 2^31 behave differently than in C signed arithmetic, appears constantly in real-world vulnerability research, including integer overflow bugs in security-critical code.

Related reading

Want more picoCTF 2021 writeups?

Useful tools for Reverse Engineering

What to try next