Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
ReverseMedium

Mvmory

CTF writeup for Mvmory from openECSC

>Mvmory

mvmory is a reverse-engineering challenge that ships as a single ELF64 binary. The prompt hints at XORs, rotations, "polynomials", and a "root" that should become printable. The goal was to recover the flag in the format openECSC{...}.

>Tools used

  • objdump, readelf, strings

  • gdb (debugger)

  • Python 3 for scripting and automating decoding

All work was done in the directory 001-mvmory.

>High-level approach

  1. Inspect the binary to find where the program builds a 43-byte buffer (the "root").

  2. Dump the computed 43-byte buffer from memory when the program runs (using gdb) to get the obfuscated bytes.

  3. Analyze the assembly to see how the binary chooses key bytes and how it XORs them into the buffer.

  4. Re-implement the index computation and reverse the XOR in Python to recover the plaintext flag.

>Static analysis

  • The ELF is x86_64 PIE and dynamically linked.

  • Disassembly of main shows it constructs a 43-byte buffer via floating-point math: it uses cvttsd2si to convert doubles to integers and stores low bytes into the buffer.

  • After constructing the buffer, main runs a loop that computes an index using a 128-bit multiply by the constant 0x4924924924924925 and a combination of arithmetic right shifts and subtractions. The final index is used to pick a byte from a key array in .rodata, then that key byte is XOR'ed with the buffer byte.

>Dynamic analysis

  • I ran the binary under gdb with a 43-byte dummy input and set a breakpoint after the buffer was populated. At that point I dumped the 43 bytes from the stack. This gave the obfuscated root bytes.

  • I also read the key bytes from the binary's .rodata at offset 0x2040..0x2060.

>Reconstructing the index mapping

The binary computes index roughly as follows (all arithmetic is 64-bit with the usual signed/unsigned behaviors where appropriate):

  • Multiply rcx by 0x4924924924924925 producing a 128-bit product in rdx:rax.

  • Perform an arithmetic right shift on rdx by 1, adjust by the sign of rcx, then compute rax = rdx*8 - rdx.

  • The final index is rcx - rax.

I implemented this exactly in Python (including a helper to perform 64-bit arithmetic-right-shift semantics), then computed indices for rcx=0..42. The output indices formed a repeating 0..6 pattern, which correspond to the first 7 bytes of the key repeating across the 43 positions.

>Deobfuscation script

I automated the above steps in 001-mvmory/decode.py:

  • Run gdb to break after the buffer fill and dump 43 bytes at rbp.

  • Read the key bytes from the binary.

  • Compute the same indices and XOR-reverse each byte to get the plaintext.

>decode.py

python
#!/usr/bin/env python3

"""

Decoder for the mvmory CTF challenge.

This script runs the provided binary under gdb to dump the 43-byte 'root' buffer,

reads the `key` bytes from the binary's .rodata, reconstructs the permutation index

exactly as the binary does, reverses the XOR and prints the recovered plaintext

(should be the flag).

  

Usage: python3 decode.py

  

This script was authored to automate the steps used during the manual analysis.

"""

  

import subprocess

import tempfile

import os

import re

import struct

  

HERE = os.path.dirname(os.path.abspath(__file__))

BINARY = os.path.join(HERE, 'mvmory', 'mvmory')

  

if not os.path.isfile(BINARY):

    print('binary not found at', BINARY)

    raise SystemExit(1)

  

# prepare a 43-byte dummy input file

tmp_input = os.path.join(tempfile.gettempdir(), 'mvmory_input_43')

with open(tmp_input, 'wb') as f:

    f.write(b'A' * 43)

  

# prepare gdb command file

gdb_cmdfile = os.path.join(tempfile.gettempdir(), 'mvmory_gdbcmds')

with open(gdb_cmdfile, 'w') as f:

    f.write('break *main+0x159\n')

    f.write(f'run < {tmp_input}\n')

    f.write('printf "rbp=0x%llx\\n", $rbp\n')

    f.write('x/43bx $rbp\n')

    f.write('quit\n')

  

# run gdb

cmd = ['gdb', '-q', '-x', gdb_cmdfile, '--args', BINARY]

print('Running gdb to dump 43 bytes from stack (root buffer) ...')

proc = subprocess.run(cmd, capture_output=True, text=True)

if proc.returncode != 0:

    print('gdb returned non-zero exit code', proc.returncode)

    print('stderr:\n', proc.stderr)

  

out = proc.stdout

# Parse gdb memory dump lines which look like:

# 0x7fffffffdb30: 0x3d    0x7f    0x56    ...

bytes_hex = []

for line in out.splitlines():

    m = re.match(r'^\s*0x[0-9a-fA-F]+:\s*(.*)$', line)

    if not m:

        continue

    # capture only 0xXX tokens on this memory line

    tokens = re.findall(r'0x([0-9a-fA-F]{2})', m.group(1))

    bytes_hex.extend(tokens)

  

if len(bytes_hex) < 43:

    print('warning: found fewer than 43 byte tokens from gdb memory dump (found', len(bytes_hex), ')')

  

root = bytes(int(h, 16) for h in bytes_hex[:43])

print('root bytes (hex):', root.hex())

print('root ascii (printable shown, others as .):', ''.join(chr(b) if 32<=b<127 else '.' for b in root))

  

# read key from binary (.rodata offset determined during analysis)

with open(BINARY, 'rb') as f:

    data = f.read()

  

# offsets discovered during static analysis

KEY_OFF = 0x2040

KEY_END = 0x2060

key = data[KEY_OFF:KEY_END]

print('key len', len(key), 'hex', key.hex())

  

# compute the same index mapping used by the binary

mask64 = (1 << 64) - 1

  

def sar64(x, shift):

    # arithmetic right shift for 64-bit value

    if x & (1 << 63):

        x_signed = x - (1 << 64)

        res = (x_signed >> shift)

        return res & mask64

    else:

        return (x >> shift) & mask64

  
  

def compute_index(rcx):

    rcx_u = rcx & mask64

    mult = (rcx_u * 0x4924924924924925) & ((1 << 128) - 1)

    rdx = (mult >> 64) & mask64

    rax = (rcx_u >> 63) & mask64

    rdx = sar64(rdx, 1)

    rdx = (rdx - rax) & mask64

    rax2 = (rdx * 8) & mask64

    rax2 = (rax2 - rdx) & mask64

    rdx2 = rcx_u

    final = (rdx2 - rax2) & mask64

    return final

  

idxs = [compute_index(i) for i in range(43)]

print('indices (first 43):', idxs)

  

# sanity check: indices should be small enough to index key

if any(i >= len(key) for i in idxs):

    print('index out of range detected; something is off with offsets/assumptions')

  

# XOR-reverse

pt = bytearray(len(root))

for i in range(len(root)):

    idx = idxs[i]

    pt[i] = root[i] ^ key[idx]

  

try:

    text = pt.decode('utf-8')

except Exception:

    text = ''.join(chr(b) if 32<=b<127 else '.' for b in pt)

  

print('\nRecovered plaintext (hex):', pt.hex())

print('Recovered plaintext (ascii):', text)

  

# cleanup temp files (optional)

# os.remove(tmp_input)

# os.remove(gdb_cmdfile)

  

if b'openECSC{' in pt:

    print('\nFlag likely:', text)

else:

    print('\nDecoded output does not contain the expected flag header; verify offsets and gdb output parsing.')

Running the script recovers the plaintext:

openECSC{ysk_h0w_t0_s0lv3_th15_w0k3n_at_00}

>Final flag

openECSC{ysk_h0w_t0_s0lv3_th15_w0k3n_at_00}

>Notes

  • The interesting part of this challenge is the integer-math based permutation using a magic multiplier. That pattern is commonly used to implement division or scaling without a direct divide instruction.

  • The floating-point math that builds the 43 bytes is obfuscation noise: it produces deterministic numeric values that eventually truncate to a byte; I bypassed reimplementing the float math by dumping the concrete bytes during execution instead of trying to symbolically or numerically reproduce the sequence.