EVM - Conditions (Blockchain)

CTF writeup for n00bzCTF 2024

EVM - Conditions

Challenge Author: NoobMaster

Description: So much maths... You need to find the value, in hex, that you need to send to make the contract STOP and not self destruct. Wrap the hex in n00bz{}. If the correct answer is 9999, the flag is n00bz{0x270f}.

Attachments: 5f600f607002610258525f60056096046090525f600760090A61FFFA526105396126aa18620bfabf52600361fffa5102620bfabf51013461025851600402016090510114604857ff00

Solution

This approach comes from not actually knowing anything about EVM or blockchain. As such, I basically treated this as a reverse engineering challenge.

Due to the previous challenge, I immediately decided to decompile this using the Dedaub decompiler.

The assembly-like representation of this code is:

    0x0: PUSH0     
    0x1: PUSH1     0xf
    0x3: PUSH1     0x70
    0x5: MUL       
    0x6: PUSH2     0x258
    0x9: MSTORE    
    0xa: PUSH0     
    0xb: PUSH1     0x5
    0xd: PUSH1     0x96
    0xf: DIV       
   0x10: PUSH1     0x90
   0x12: MSTORE    
   0x13: PUSH0     
   0x14: PUSH1     0x7
   0x16: PUSH1     0x9
   0x18: EXP       
   0x19: PUSH2     0xfffa
   0x1c: MSTORE    
   0x1d: PUSH2     0x539
   0x20: PUSH2     0x26aa
   0x23: XOR       
   0x24: PUSH3     0xbfabf
   0x28: MSTORE    
   0x29: PUSH1     0x3
   0x2b: PUSH2     0xfffa
   0x2e: MLOAD     
   0x2f: MUL       
   0x30: PUSH3     0xbfabf
   0x34: MLOAD     
   0x35: ADD       
   0x36: CALLVALUE 
   0x37: PUSH2     0x258
   0x3a: MLOAD     
   0x3b: PUSH1     0x4
   0x3d: MUL       
   0x3e: ADD       
   0x3f: PUSH1     0x90
   0x41: MLOAD     
   0x42: ADD       
   0x43: EQ        
   0x44: PUSH1     0x48
   0x46: JUMPI     
   0x47: SELFDESTRUCT
   0x48: STOP       

Each line contains an address for the instruction, followed by an opcode and, optionally an argument (really only for the PUSH opcodes).

This language revolves around a stack, so most opcodes use the top items on the stack as their arguments.

This challenge also makes use of the memory, which is a separate structure in the EVM language. The MSTORE opcode stores a value in memory, and the MLOAD opcode loads a value from memory.

Using the Ethereum yellow paper, I was able to understand the function of each opcode. See Appendix H.2 for the full list with explanations.

This one is a bit more complex than the previous challenge, but the goal is the same. We want to reach the STOP opcode without going through the SELFDESTRUCT opcode. We need to figure out what value is inputted with the CALLVALUE opcode to make the EQ opcode return 1, so that the JUMPI opcode will jump to the STOP opcode.

Due to the length of this challenge, I implemented a little interpreter in Python to help me manage the stack and memory until I reached the point where an input was needed.

code = """0x0: PUSH0 
0x1: PUSH1 0xf
0x3: PUSH1 0x70
0x5: MUL 
0x6: PUSH2 0x258
0x9: MSTORE 
0xa: PUSH0 
0xb: PUSH1 0x5
0xd: PUSH1 0x96
0xf: DIV 
0x10: PUSH1 0x90
0x12: MSTORE 
0x13: PUSH0 
0x14: PUSH1 0x7
0x16: PUSH1 0x9
0x18: EXP 
0x19: PUSH2 0xfffa
0x1c: MSTORE 
0x1d: PUSH2 0x539
0x20: PUSH2 0x26aa
0x23: XOR 
0x24: PUSH3 0xbfabf
0x28: MSTORE 
0x29: PUSH1 0x3
0x2b: PUSH2 0xfffa
0x2e: MLOAD 
0x2f: MUL 
0x30: PUSH3 0xbfabf
0x34: MLOAD 
0x35: ADD 
0x36: CALLVALUE
0x37: PUSH2 0x258
0x3a: MLOAD 
0x3b: PUSH1 0x4
0x3d: MUL 
0x3e: ADD 
0x3f: PUSH1 0x90
0x41: MLOAD 
0x42: ADD 
0x43: EQ 
0x44: PUSH1 0x48
0x46: JUMPI 
0x47: SELFDESTRUCT
0x48: STOP""".split("\n")

prog_lines = {}

stack = []
memory = {}

# create a dictionary of the program, mapping instruction addresses to opcodes
for line in code:
    if line:
        line = line.split(": ")
        prog_lines[int(line[0], 16)] = line[1].strip()

# quick debug function to print stack with decimal and hex values
def stack_print():
    print("Stack: ", [str(x) + "=" + hex(x) for x in stack])

# function to run a single instruction
# returns the address of the next instruction to run
def run(address : int, op: str) -> int:
    # input() # uncomment this line to step through the program line by line

    global stack
    global memory
    global storage

    if op=="PUSH0":
        stack.append(0)

    elif op.startswith("PUSH"):
        stack.append(int(op.split(" ")[1], 16))

    elif op=="MSTORE":
        mem_addr = stack.pop()
        val = stack.pop()
        memory[mem_addr] = val

    elif op=="MLOAD":
        mem_addr = stack.pop()
        stack.append(memory[mem_addr])

    elif op=="ADD":
        stack.append(stack.pop() + stack.pop())

    elif op=="SUB":
        stack.append(stack.pop() - stack.pop())

    elif op=="MUL":
        stack.append(stack.pop() * stack.pop())

    elif op=="DIV":
        stack.append(stack.pop() // stack.pop())

    elif op=="EXP":
        stack.append(stack.pop() ** stack.pop())

    elif op=="XOR":
        stack.append(stack.pop() ^ stack.pop())

    elif op=="CALLVALUE":
        stack.append(int(input("Enter value: "), 16))

    elif op=="JUMPI":
        go = stack.pop()
        if stack.pop() != 0:
            return go
        
    elif op=="EQ":
        stack.append(1 if stack.pop() == stack.pop() else 0)

    elif op=="SELFDESTRUCT":
        print("failed")
        exit(1)

    elif op=="STOP":
        print("success")
        exit(0)
    
    # if no jump occurred, go to the next instruction
    return address + 1

# what instruction we are currently on
program_counter = 0

# loop until we reach the greatest instruction address
while program_counter <= max(prog_lines.keys()):
    # skip over instruction addresses that don't exist 
    # (for some reason the addresses are not contiguous)
    while program_counter not in prog_lines.keys():
        program_counter += 1
        continue

    # print the current instruction and run it
    op = prog_lines[program_counter]
    print(hex(program_counter), op)
    program_counter = run(program_counter, op)

    # print the current state of the stack and memory
    print(memory)
    stack_print()

This runs the program, and when it reaches the CALLVALUE opcode, it will prompt the user.

0x35 ADD
{600: 1680, 144: 30, 65530: 4782969, 785087: 9107}
Stack:  ['0=0x0', '0=0x0', '0=0x0', '14358014=0xdb15fe']
0x36 CALLVALUE
Enter value: |

I used a python dictionary to store the memory values, so the key is the address in memory and the value is the value stored there. The stack is just a list of values, with the top being the end of the list.

We can use the current state of the stack and memory to manually step through the rest of the opcodes. Let X be the value we inputted with CALLVALUE.

Bytecode
0x35: ADD
0x36: CALLVALUE
0x37: PUSH2 0x258
0x3a: MLOAD 
0x3b: PUSH1 0x4
0x3d: MUL 
0x3e: ADD 
0x3f: PUSH1 0x90
0x41: MLOAD 
0x42: ADD 
0x43: EQ 
0x44: PUSH1 0x48
0x46: JUMPI 
0x47: SELFDESTRUCT
0x48: STOP

The Stack (ignoring leading zeros)

0x35: [0xdb15fe] 
0x36: [0xdb15fe, X] 
0x37: [0xdb15fe, X, 0x258] 
0x3a: [0xdb15fe, X, 0x690] 
0x3b: [0xdb15fe, X, 0x690, 0x4] 
0x3d: [0xdb15fe, X, 0x1a40] 
0x3e: [0xdb15fe, X + 0x1a40] 
0x3f: [0xdb15fe, X + 0x1a40, 0x90] 
0x41: [0xdb15fe, X + 0x1a40, 0x1e] 
0x42: [0xdb15fe, X + 0x1a40 + 0x1e] 
0x43: [int(0xdb15fe == X + 0x1a40 + 0x1e)] 
0x44: [int(0xdb15fe == X + 0x1a40 + 0x1e), 0x48] 
0x46: [] 
Memory
0x29-0x48: {
    0x90: 0x1e,
    0x258: 0x690,
    0xfffa: 0x48fb79,
    0xbfabf: 0x2393
}

As we can see, we need to input some value X such that 0xdb15fe == X + 0x1a40 + 0x1e.

>>> hex(0xdb15fe - (0x1a40 + 0x1e))
'0xdafba0'

If you input 0xdafba0 into the script when prompted, the program will reach the STOP opcode and print "success".

Flag

Now, we just wrap it in the flag format:

n00bz{0xdafba0}

I definitely didn't spend thirty minutes debugging cuz I had the flag format wrong... 😭

Tags:

blockchain,

reverse engineering,

n00bzctf,

ctf