>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
-
Inspect the binary to find where the program builds a 43-byte buffer (the "root").
-
Dump the computed 43-byte buffer from memory when the program runs (using gdb) to get the obfuscated bytes.
-
Analyze the assembly to see how the binary chooses key bytes and how it XORs them into the buffer.
-
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
mainshows it constructs a 43-byte buffer via floating-point math: it usescvttsd2sito convert doubles to integers and stores low bytes into the buffer. -
After constructing the buffer,
mainruns a loop that computes an index using a 128-bit multiply by the constant0x4924924924924925and a combination of arithmetic right shifts and subtractions. The final index is used to pick a byte from akeyarray 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
rootbytes. -
I also read the
keybytes from the binary's .rodata at offset0x2040..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
0x4924924924924925producing a 128-bit product inrdx:rax. -
Perform an arithmetic right shift on
rdxby 1, adjust by the sign of rcx, then computerax = 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
#!/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.