blog > Hack-a-Sat 4 Qualifiers Writeups

3 Apr 2023 ctfrev

Hack-a-Sat 4 Qualifiers Writeups

I played with Tea MSG and we placed 14th this year. I managed to solve all 4 challenges from the Rev / PPC challege category “The Magician” among others.

As Below

Every journey begins by blazing a path.

The answers are in the binaries.

nc as_below.quals2023-kah5aiv9.satellitesabove.me 5300

Attachment: as-below.tar.bz2

35 solves, 109 points

The attachment contains 78 WebAssembly binaries which can be run using wasmtime. Each of them asks for some decimal number input and somehow checks the value. Communicating with the challenge server involves it requesting for a value that passes checks in some specific binary, in the flavor of 1000cuts from previous DEFCON CTFs or Blazin’ Etudes from previous Hack-a-Sat CTFs.

We can decompile one binary to look for the checking function which is called w2c_quick_maths in the decompile. It takes in the user-supplied input u32 and returns a check bool.

wasm2c fool > fool.c
c
static u32 w2c_quick_maths(Z__instance_t* instance, u32 w2c_p0) {
  u32 w2c_l1 = 0, w2c_l2 = 0, w2c_l3 = 0, w2c_l4 = 0, w2c_l5 = 0, w2c_l6 = 0, w2c_l7 = 0, w2c_l8 = 0,
      // [... many vars]
      w2c_l57 = 0, w2c_l58 = 0, w2c_l59 = 0, w2c_l60 = 0, w2c_l61 = 0, w2c_l62 = 0, w2c_l63 = 0, w2c_l64 = 0;
  FUNC_PROLOGUE;
  u32 w2c_i0, w2c_i1;
  w2c_i0 = instance->w2c_g0;
  w2c_l1 = w2c_i0;
  w2c_i0 = 16u;
  w2c_l2 = w2c_i0;
  // [... many lines]
  w2c_i1 = w2c_l63;
  w2c_i0 &= w2c_i1;
  w2c_l64 = w2c_i0;
  w2c_i0 = w2c_l64;
  goto w2c_Bfunc;
  w2c_Bfunc:;
  FUNC_EPILOGUE;
  return w2c_i0;
}

We can filter out some important lines of the decompilation.

fool.c
c
static u32 w2c_quick_maths(Z__instance_t* instance, u32 w2c_p0) {
  // final checking number stored in w2c_l4
  w2c_i0 = 9119124u;
  w2c_l4 = w2c_i0;

  // user number is loaded and stored in stack
  w2c_i1 = w2c_p0;
  i32_store(&instance->w2c_memory, (u64)(w2c_i0) + 12, w2c_i1);

  // then loaded from stack
  w2c_i0 = i32_load(&instance->w2c_memory, (u64)(w2c_i0) + 12u);
  // and modified
  w2c_i0 = 569u;
  w2c_i0 += w2c_i1;
  // then stored
  i32_store(&instance->w2c_memory, (u64)(w2c_i0) + 12, w2c_i1);

  // and modified again
  w2c_i0 = 3u;
  w2c_i0 = DIV_U(w2c_i0, w2c_i1);
  // and again
  w2c_i0 = 6u;
  w2c_i0 = DIV_U(w2c_i0, w2c_i1);
  // and again
  w2c_i0 = 4u;
  w2c_i0 >>= (w2c_i1 & 31);
  // many more of these operations, and finally

  // accumulator's value is checked
  w2c_i0 = w2c_i0 == w2c_i1;
  w2c_i0 = 1u;

  // and returned
  return w2c_i0;
}

To generate a valid key for some binary, we can extract the important operations done on the user’s input and use z3 to solve for the input.

Solve for input
ipython
import base64
import glob
from tqdm.auto import tqdm

from z3 import *

def get_immediate(x):
    # convert constants to BitVec e.g. w2c_i0 = 16ull;` => 16 in BitVec
    return BitVecVal(int(x.partition("=")[2].strip("ul; ")), 32)

def solve_binary(filename):
    wasmpath = f"./as-below/{filename}"
    cpath = f"{wasmpath}.c"

    # Decompile to c
    !wasm2c $wasmpath > $cpath
    lines = open(cpath, "r").read().split("\n")
    !rm $cpath

    # Read file and extract lines of w2c_quick_maths
    lines = lines[lines.index("static u32 w2c_quick_maths(Z__instance_t* instance, u32 w2c_p0) {"):]
    lines = lines[:lines.index("}")]

    # Jump to known prologue
    lines = lines[lines.index("  w2c_i0 = instance->w2c_g0;"):]
    head = [
        '  w2c_i0 = instance->w2c_g0;',
        '  w2c_l1 = w2c_i0;',
        '  w2c_i0 = 16u;',
        '  w2c_l2 = w2c_i0;',
        '  w2c_i0 = w2c_l1;',
        '  w2c_i1 = w2c_l2;',
        '  w2c_i0 -= w2c_i1;',
        '  w2c_l3 = w2c_i0;',
    ]
    assert lines[:len(head)] == head
    lines = lines[len(head):]

    # extract operations and check
    operations = list(zip(lines[8::12], lines[12::12]))[:-2]
    check = lines[0]

    # solve for input
    xref = BitVec("x", 32)
    x = xref
    for y, op in operations:
        y = get_immediate(y)
        if "+=" in op:
            x = x + y
        elif "-=" in op:
            x = x - y
        elif "*=" in op:
            x = x * y
        elif "DIV_U" in op:
            x = UDiv(x, y) # flooring integer division, not floating point
        elif "<<=" in op:
            x = x << y
        elif ">>=" in op:
            x = LShR(x, y) # u32 is used, bitshift is logical
        else:
            raise ValueError()
    s = Solver()
    s.add(x == get_immediate(lines[0]))
    assert str(s.check()) == "sat"

    soln = s.model()[xref].as_long()
    solnb64 = base64.b64encode(str(soln).encode()).decode()
    return soln, solnb64

for f in tqdm(sorted(glob.glob("as-below/*"))):
    binary = f.partition("/")[2]
    ans = solve_binary(binary)
    assert ans
    print(f, ans)
output
as-below/ace-of-cups (611278624, 'NjExMjc4NjI0')
as-below/ace-of-pentacles (12963207, 'MTI5NjMyMDc=')
as-below/ace-of-swords (2750602715, 'Mjc1MDYwMjcxNQ==')
as-below/ace-of-wands (514591455, 'NTE0NTkxNDU1')
as-below/chariot (4073946498, 'NDA3Mzk0NjQ5OA==')
[...]
as-below/two-of-wands (3484324879, 'MzQ4NDMyNDg3OQ==')
as-below/wheel-of-fortune (3611342052, 'MzYxMTM0MjA1Mg==')
as-below/world (3852018796, 'Mzg1MjAxODc5Ng==')

Finally, we can communicate with the server to get the flag.

Claim flag
python
from pwn import *

r = remote("as_below.quals2023-kah5aiv9.satellitesabove.me", 5300)
r.sendline(b"ticket{xray767199juliet4:GPvf2Y6YNUCiazDTbJkdshfXx4DJRekMIesXKNYA_uy7xSpWhtxXDl8fOUwbuqHePQ}")
r.recvuntil(b"send your solution as base64, followed by a newline\n")

# post-hoc info that there is 10 testcases
for _ in tqdm(range(10)):
    binary = r.recvline().decode().partition("\t")[0]
    r.sendline(solve_binary(binary)[1].encode())
print(r.recvline())
print(r.recvline())
output
b"You did it - here's the flag\n"
b'flag{xray767199juliet4:GPTnbYn6p73eM4xko6SCwoLBhgNDkompfd4-QNt5xn7nwo1EQDpmuRxELfqkaQYuL5wBn65DB62SHWgX5bl9wAY}\n'

Leavenworth Street

A twisty path beckons, will you find your way?

https://leavenworth-street-vae0chei.eames.satellitesabove.me

plaintext
container_name=$(docker create --rm --env "FLAG=${flag}" --env "SEED=${seed}" $image_name)
docker cp "$submission_path" "$container_name:/solver/solver.ts"
docker start --attach "$container_name"

Attachment: leavenworth-street-20230330.docker.bz2

24 solves, 142 points

We get a docker image and instructions to load in a user’s solver.ts, which will eventually be uploaded to a judging server. Some recon on the docker image reveals that there is a Crystal installation and that there are challenge files in /chall/bin, which we can leak using some docker commands.

text
$ docker load -i ./leavenworth-street-20230330.docker.bz2
Loaded image: leavenworth-street:latest
$ docker image history leavenworth-street:latest --format json --no-trunc
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN doas -n -u deno /usr/bin/deno run --allow-all vendor.ts","CreatedSince":"N/A","ID":"sha256:6d816ea8da53cac5dce64d331d4c2c5d14fd5f56377e4c3b57cb1edc52e9c547","Size":"45.2kB"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"COPY --chown=chall:chall vendor.ts /chall/vendor.ts","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"100B"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"COPY --chown=chall:chall --from=builder /chall/bin/leavenworth /chall/bin/leavenworth","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"3.07MB"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN addgroup chall --gid 1000 \u0026\u0026   adduser --disabled-password --uid 1000 --ingroup chall --home /chall chall \u0026\u0026   chown -R chall:chall /chall /solver","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"651kB"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN chmod go-wx /etc/doas.conf","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"90B"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"COPY --chown=root:root ./doas.conf /etc/doas.conf","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"90B"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN mkdir -p /chall/bin /solver","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"0B"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN apt-get update \u0026\u0026   apt-get install -y curl git doas \u0026\u0026   curl -fsSL https://crystal-lang.org/install.sh | bash","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"511MB"}
...
$ container_name=$(docker create --rm leavenworth-street:latest)
$ docker cp "$container_name:/chall/" "./chall/"
Successfully copied 3.077MB to /home/sy/ctf2/has4/wps/leavenworth-street/chall/
$ find chall -type f
chall/vendor.ts
chall/bin/leavenworth

Decompiling the leavenworth binary tells us that it generates a 2d maze, sends it to the user’s solver.ts and expects a sequence of NSWE moves which will solve the maze moving from start cell S to finish cell F. Standard error of the solver.ts script is also piped to the binary output, which will help with debugging. With this info, it’s just a matter of coding out the maze solver using a flood-fill algorithm and submitting it to the online judge.

Helper script localrun.sh
bash
#!/usr/bin/env bash
image_name=hyde-street:latest
submission_path=$(pwd)/submission.ts
container_name=$(docker create --rm --env "FLAG=${flag}" --env "SEED=${seed}" $image_name)
docker cp "$submission_path" "$container_name:/submission.ts"
docker start --attach "$container_name"
Maze solver script solver.ts
ts
import { BufReader } from "https://deno.land/std@0.178.0/io/buf_reader.ts";

// Helper functions
const decoder = new TextDecoder();
const dec = (t: Uint8Array) => decoder.decode(t);
const encoder = new TextEncoder();
const enc = (t: string) => encoder.encode(t);
const errLog = (t: Object) =>
  Deno.stderr.writeSync(enc(Deno.inspect(t) + "\n"));

const bufReader = new BufReader(Deno.stdin);
// Read in number of bytes to load the maze grid
const mazeNBytes = +dec((await bufReader.readLine()).line);
// errLog(mazeNBytes);
// Read in the maze grid
const mazeBuf = new Uint8Array(mazeNBytes);
await bufReader.read(mazeBuf);
const maze = dec(mazeBuf);
// errLog(maze);

// Extract grid landmarks
const grid = maze.split("\n").map((row) => row.split(""));
const [r, c] = [grid.length, grid[0].length];
// errLog(`r:${r}, c:${c}`)
let [sy, sx, fy, fx] = [-1, -1, -1, -1];
for (const y in grid) {
  for (const x in grid[y]) {
    const cell = grid[y][x];
    if (cell == "S") (sy = +y), (sx = +x);
    if (cell == "F") (fy = +y), (fx = +x);
  }
}
// errLog(`s:(${sy},${sx}), f:(${fy},${fx})`)

// Adjacency
const d4: [number, number, string, string][] = [
  [0, 1, "E", "W"],
  [0, -1, "W", "E"],
  [1, 0, "S", "N"],
  [-1, 0, "N", "S"],
];

// Flood fill maze solving
type Breadcrumb = [number, number, string];
const flood: (Breadcrumb | null)[][] = grid.map((r) => r.map((c) => null));
function dfs(y: number, x: number, breadcrumb: Breadcrumb) {
  if (y < 0 || y >= r) return;
  if (x < 0 || x >= c) return;
  if (flood[y][x] !== null) return;
  if (grid[y][x] === "X") return;

  flood[y][x] = breadcrumb;
  for (const [dy, dx, fwd, rev] of d4) {
    const ny = y + dy;
    const nx = x + dx;
    dfs(ny, nx, [y, x, fwd]);
  }
}

dfs(sy, sx, [sy, sx, "?"]);
// errLog(flood);
for (const y in grid) {
  errLog(flood[y].map((cell) => (cell || [0, 0, "#"])[2]).join(""));
}

// Backtrack to get the solve path
const backtrack: string[] = [];
while (fy != sy || fx != sx) {
  const [ny, nx, fwd] = flood[fy][fx] as Breadcrumb;
  [fy, fx] = [ny, nx];
  backtrack.push(fwd);
}
backtrack.reverse();
const solution = backtrack.join("");
errLog(solution);

// Submit to grader
console.log(solution);
output
# On server
2023-04-03T16:20:42.730170Z   INFO - starting run 0
2023-04-03T16:20:42.731356Z   INFO - Started player process 11
S  X
 X X X
 X   X
XXXXXX
     X
 XXX X
   X  F
2023-04-03T16:20:42.790935Z   INFO - finished! woo
2023-04-03T16:20:42.790955Z   INFO - killing 11
2023-04-03T16:20:42.791927Z   INFO - starting run 1
2023-04-03T16:20:42.793879Z   INFO - Started player process 15
S      X
XXXX X X
     X
[...]
 X     X X   X   X X X
 X XXXXX X X XXX X X XXXX
 X       X X     X X    F
2023-04-03T16:20:43.176660Z   INFO - finished! woo
2023-04-03T16:20:43.176677Z   INFO - killing 47
flag{tango699577lima4:GAw_H-ggtijuwUghHo9Ktw9om5ZPG2-mtxQG-CW5kG4VvoC66tqxr_UxL16egYQZE3PA-xGSB8Id6CsXHHzT6R0}

Hyde Street

Top of the hill, at the source.

But the sky still beckons.

https://hyde-street-yae9ushu.eames.satellitesabove.me

plaintext
container_name=$(docker create --rm --env "FLAG=${flag}" --env "SEED=${seed}" $image_name)
docker cp "$submission_path" "$container_name:/submission.ts"
docker start --attach "$container_name"

Attachment: hyde-street-20230330.docker.bz2

23 solves, 146 points

We get a docker image and instructions to load in a user’s submission.ts, which will also eventually be uploaded to a judging server. Challenge files have once again appeared in /chall, and we can leak them in the same way as for Leavenworth Street.

text
$ docker load -i hyde-street-20230330.docker.bz2
Loaded image: hyde-street:latest
$ docker image history hyde-street:latest --format json --no-trunc
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"COPY --chown=1000:1000 . /chall/","CreatedSince":"N/A","ID":"sha256:0398372cec81d3623d9caddc7e675f3c07433713942bb98fe50bb4f9b922786c","Size":"4.59kB"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN mkdir /chall \u0026\u0026   chown -R 1000:1000 /chall /deno-dir","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"0B"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN apt-get update -y \u0026\u0026 apt-get install -y build-essential git npm ruby     \u0026\u0026 apt-get clean \u0026\u0026 rm -f /var/lib/apt/lists/*_*","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"729MB"}
...
$ container_name=$(docker create --rm hyde-street:latest)
$ docker cp "$container_name:/chall/" "./chall/"
Successfully copied 12.29kB to /home/sy/ctf2/has4/wps/hyde-street/chall/
$ find chall -type f
chall/src/main.c
chall/driver.rb
chall/.gitignore
chall/wrapper.sh
chall/Dockerfile
chall/Makefile
chall/Dockerfile-dev

Looking at /chall/driver.rb, it first randomly generates a quick_maths.c containing a function bool quick_maths(uint32_t run) that does a series of math operations on the user’s run input and checks if the output is equal to some desired value. It then compiles the challenge binary and runs the user’s submission.ts with explicit read permissions to the source quick_maths.c and expects the script to output some u32 that passes the checking funcion.

Challenge driver.rb
ruby
require 'digest'
require 'fileutils'
require 'logger'
require 'json'
require 'set'

UINT32_MAX = 0xffffffff

NUMBER_TO_GENERATE = 10

def num_in_range(op)
  case op
  when :/
    rand(2..16)
  when :*
    rand(2..32)
  when :-
    rand(0..256)
  when :+
    rand(0..16384)
  else
    fail "couldn't num_in_range for #{op}"
  end
end


FileUtils.mkdir_p "challs"

dest = File.join __dir__, 'src', 'quick_maths.c'

NUMBER_TO_GENERATE.times do |n|
  out_f = File.open dest, 'w'

  out_f.puts <<~EOS.strip
  #include <stdint.h>
  #include <stdbool.h>

  bool quick_maths(uint32_t run) {
  EOS

  want = rand(0..UINT32_MAX)
  run = want

  rand(10..20).times do
    stmt = nil
    result = nil

    loop do
      # skipping multiplication for now; idiv vs. fdiv ;_;
      operation = %i{+ - /}.sample
      operand = num_in_range(operation)

      result = run.send operation, operand

      # next here loops again
      next if result >= UINT32_MAX
      next if result <= 0

      stmt = "run = run #{operation} #{operand};"
      stmt += " // #{result}" if ENV['CHALLENGE_DEV_DEBUG']
      break
    end

    out_f.puts stmt
    run = result
  end


  out_f.puts <<~EOS.strip
  return (run == #{ run });
  }
  EOS

  out_f.close

  `make clean all`


  size = `wc -c build/ominous_etude`.split[0].to_i
  digest = `sha256sum build/ominous_etude`.split[0]

  new_omen_name = "generated"
  FileUtils.mv 'build/ominous_etude', "challs/#{new_omen_name}"
  FileUtils.mv dest, "challs/#{new_omen_name}.c"

  $stderr.puts JSON.dump({
    'sha256' => digest,
    'size' => size,
    'answer' => want.to_s
  })

  got = nil


  IO.popen("deno run --no-remote --allow-read=/chall/challs/generated,/chall/challs/generated.c --allow-env=SOLVER_DEBUG /submission.ts #{new_omen_name}",
    'r+',
    # should redirect child stderr to my stdout
    :err => :out) do |line|
      got = line.gets
      line.close
  end

  puts got
  puts got.to_i == want

  IO.popen("/chall/challs/#{new_omen_name}", 'w+', :err => :out) do |result|
    result.puts got
    puts result.gets
    puts did_get = result.gets.strip
    result.close
    unless "cool :)" == did_get
      puts result.puts
      puts "got a wrong answer"
      exit 1
    end
  end
end

puts ENV['FLAG'] || "no flag set in environment"

Since the submission script has read access to the source of quick_maths(), we can obtain the ending check value and the intermediate operations done on the user input run, then work backwards to obtain some input that passes the check.

Helper script localrun.sh
bash
#!/usr/bin/env bash
image_name=hyde-street:latest
submission_path=$(pwd)/submission.ts
container_name=$(docker create --rm --env "FLAG=${flag}" --env "SEED=${seed}" $image_name)
docker cp "$submission_path" "$container_name:/submission.ts"
docker start --attach "$container_name"
Keygen script submission.ts
ts
// Helper functions
const decoder = new TextDecoder();
const dec = (t: Uint8Array) => decoder.decode(t);
const encoder = new TextEncoder();
const enc = (t: string) => encoder.encode(t);
const errLog = (t: Object) =>
  Deno.stderr.writeSync(enc(Deno.inspect(t) + "\n"));

// Load source file
const generatedC = await Deno.readTextFile("/chall/challs/generated.c");
const sourceLines = generatedC.split("\n");

// Parse operations
const operations: [string, number][] = [];
let checkValue = -1;
for (let sourceLine of sourceLines) {
  // errLog(sourceLine);

  const checkValueMatch = /return \(run == (\d+)\);/g.exec(sourceLine);
  if (!!checkValueMatch) {
    checkValue = +checkValueMatch[1];
  }

  const operationMatch = /run = run (.) (\d+);/g.exec(sourceLine);
  if (!!operationMatch) {
    const op = operationMatch[1];
    const y = +operationMatch[2];
    operations.push([op, y]);
  }
}

errLog("operations:");
errLog(operations);
errLog("checkValue:");
errLog(checkValue);

// Backtrack
let current = checkValue;
operations.reverse();
for (const [op, y] of operations) {
  switch (op) {
    case "+":
      current -= y;
      break;
    case "-":
      current += y;
      break;
    case "/":
      current *= y;
      break;
  }
}
errLog("solution: " + current);
console.log(current);
output
# On server
"operations:"
[
  [ "/", 14 ],   [ "-", 214 ],
  [ "-", 165 ],  [ "-", 248 ],
  [ "-", 143 ],  [ "/", 6 ],
  [ "/", 9 ],    [ "+", 1267 ],
  [ "+", 6230 ], [ "-", 142 ],
  [ "/", 10 ],   [ "+", 6826 ],
  [ "/", 8 ],    [ "/", 6 ],
  [ "-", 97 ],   [ "+", 4151 ],
  [ "-", 50 ],   [ "/", 3 ]
]
"checkValue:"
4880
"solution: 3802437520"
3802437520
false
enter decimal number:
cool :)
"operations:"
[
  [ "-", 57 ],   [ "+", 4828 ],
  [ "-", 36 ],   [ "/", 10 ],
[...]
  [ "/", 14 ],    [ "+", 1887 ],
  [ "-", 165 ],   [ "+", 2545 ]
]
"checkValue:"
39496
"solution: 398112335"
398112335
false
enter decimal number:
cool :)
flag{delta524838golf4:GIE0Bp_V4CTSvma9SF6jZIdb5fYgh1MWRzVmLqvkh34bZBhGkHT-oR_XBkmNIT9xrf4zeDzLjPoC8MZWHOJIiCI}

So Above

No matter how far a path you blaze, there you are.

The answers are in the binaries.

https://so-above-soo2epha.eames.satellitesabove.me

plaintext
container_name=$(docker create --rm --env "FLAG=${flag}" --env "SEED=${seed}" $image_name)
docker cp "$submission_path" "$container_name:/submission.ts"
docker start --attach "$container_name"

Attachment: template.ts so-above-20230330.docker.bz2

19 solves, 165 points

This challenge’s setup is very similar to that of Hyde Street. There is a new build step in the container that installs several javascript packages from vendor.ts.

text
$ docker load -i hyde-street-20230330.docker.bz2
Loaded image: so-above:challenge
$ docker image history so-above:challenge --format json --no-trunc
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN deno install vendor.ts \u0026\u0026   deno run --allow-all vendor.ts","CreatedSince":"N/A","ID":"sha256:da08fc1d1263ce45fea95739115b61c7e37a8563af9f36da79c24e77921bcfce","Size":"6.56MB"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"COPY --chown=1000:1000 . /chall/","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"6.12kB"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN mkdir /chall \u0026\u0026   chown -R 1000:1000 /chall /deno-dir","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"0B"}
{"Comment":"","CreatedAt":"0001-01-01T06:55:25+06:55","CreatedBy":"RUN apt-get update -y \u0026\u0026 apt-get install -y build-essential git npm ruby     \u0026\u0026 apt-get clean \u0026\u0026 rm -f /var/lib/apt/lists/*_*","CreatedSince":"N/A","ID":"\u003cmissing\u003e","Size":"729MB"}
$ container_name=$(docker create --rm so-above:challenge)
$ docker cp "$container_name:/chall/" "./chall/"
Successfully copied 17.41kB to /home/sy/ctf2/has4/wps/so-above/chall/
$ find chall -type f
chall/src/main.c
chall/driver.rb
chall/.gitignore
chall/wrapper.sh
chall/deno.lock
chall/vendor.ts
chall/Dockerfile
chall/Makefile
chall/import_map.json
chall/deno.jsonc
chall/Dockerfile-dev

On extracting the challenge files we note several differences in driver.rb as well:

  • The quick_maths function now operates on doubles
  • The deno script is run with access to preinstalled packages such as iced-x86 and elfinfo
  • The solver script only has access to the compiled binary and cannot read quick_maths.c anymore
diff hyde-street/chall/driver.rb so-above/chall/driver.rb
diff
9c9
< NUMBER_TO_GENERATE = 10
---
> NUMBER_TO_GENERATE = (ENV['NUMBER_TO_GENERATE'] || 10).to_i
35d34
<   #include <stdint.h>
38c37
<   bool quick_maths(uint32_t run) {
---
>   bool quick_maths(double run) {
94,95c93
<
<   IO.popen("deno run --no-remote --allow-read=/chall/challs/generated,/chall/challs/generated.c --allow-env=SOLVER_DEBUG /submission.ts #{new_omen_name}",
---
>   IO.popen("deno run --cached-only --allow-read=/chall/challs/generated,/deno-dir --allow-env=SOLVER_DEBUG /submission.ts #{new_omen_name}",
119c117
< puts ENV['FLAG'] || "no flag set in environment"
---
> puts ENV['FLAG'] || "pretend the flag is here"

Looks like the task of this challenge is to automate a keygen for arbitary binaries.

We can leak some example binaries by submitting an infinite loop script and copying out the binary generated and source generated.c (which the script will not be able to read).

Helper script leakbin.sh
bash
#!/usr/bin/env bash
image_name=so-above:challenge
submission_path=$(pwd)/lag.ts
container_name=$(docker create --rm --env "FLAG=${flag}" --env "SEED=${seed}" $image_name)
echo "await new Promise((resolve) => { setTimeout(resolve, 60000); })" > $submission_path
docker cp "$submission_path" "$container_name:/submission.ts"
docker start "$container_name"
sleep 1
mkdir -p $(pwd)/leak-challs
docker cp "$container_name:/chall/challs/generated" "$(pwd)/leak-challs/generated"
docker cp "$container_name:/chall/challs/generated.c" "$(pwd)/leak-challs/generated.c"
docker kill "$container_name"

For example, one generated.c may look like the following:

c
#include <stdbool.h>

bool quick_maths(double run) {
run = run + 6067;
run = run / 14;
run = run / 16;
run = run + 3111;
run = run + 9812;
run = run / 14;
run = run / 4;
run = run + 2449;
run = run + 16373;
run = run / 16;
run = run / 6;
run = run / 10;
run = run + 673;
run = run - 203;
run = run - 6;
return (run == 729);
}

While cross-checking with ghidra, using the supplied libraries we can use elfdata to locate code sections and where constants are stored.

Load binary sections
ts
import iced from "npm:iced-x86@1.18.0";
import elfinfo from "npm:elfinfo@0.4.0-beta";
import predicates from "npm:@tool-belt/type-predicates@1.2.2";

// Helper functions
const decoder = new TextDecoder();
const dec = (t: Uint8Array) => decoder.decode(t);
const encoder = new TextEncoder();
const enc = (t: string) => encoder.encode(t);
const errLog = (t: Object) =>
  Deno.stderr.writeSync(enc(Deno.inspect(t) + "\n"));

// For server
// const binary = await Deno.readFile("/chall/challs/generated")
// For local testing
const binary = await Deno.readFile("./leak-challs/generated");
const elfdata = await elfinfo.open(binary);
errLog(Object.keys(elfdata.elf));

const sections = elfdata.elf?.sections as elfinfo.ELFSection[];
const symtab = sections.find((x) => x.name === ".symtab") as elfinfo.ELFSection;
const rodata = sections.find((x) => x.name === ".rodata") as elfinfo.ELFSection;
errLog(Object.keys(symtab));
output
[
  "path",
  "class",
  "classDescription",
  [...]
  "shstrIndex",
  "segments",
  "sections"
]
[
  "index",            "name",
  "nameix",           "type",
  [...]
  "addralign",        "entsize",
  "symbols"
]

We can take a slice of rodata and cast it to a Float64Array to find the floating point constants used inside quick_maths(). Notably if some values are reused in multiple places of the binary they are only stored once e.g. 16 and 6 in the earlier generated.c. The earlier values correspond to the operands of each add / subtract / divide operation while the last value is usually the expected final value.

Extract double constants
ts
const rodataCode = binary.slice(
  Number(rodata.addr) + 0x40,
  Number(rodata.addr) + rodata.size
);
const rodataBuf = new ArrayBuffer(rodataCode.length);
const rodataBytes = new Uint8Array(rodataBuf);
const rodataDoubles = new Float64Array(rodataBuf);
rodataCode.forEach((x, i) => (rodataBytes[i] = x));
errLog(rodataDoubles);
output
Float64Array(13) [
  6067, 14,   16,  3111,
  9812,  4, 2449, 16373,
     6, 10,  673,   203,
   729
]

Next, the location of quick_maths() can be found and we can use iced to disassemble the function.

Disassemble quick_maths
ts
const quickmathsSym = (symtab as any).symbols.find(
  (x) => x.name === "quick_maths"
);
const quickmathsCode = binary.slice(
  Number(quickmathsSym.value),
  Number(quickmathsSym.value) + quickmathsSym.size
) as Uint8Array;

const instrDecoder = new iced.Decoder(
  64,
  quickmathsCode,
  iced.DecoderOptions.None
);
const instructions = instrDecoder.decodeAll();
const formatter = new iced.Formatter(iced.FormatterSyntax.Nasm);

for (const instructionIdx in instructions) {
  const instr = instructions[instructionIdx];
  const disasm = formatter.format(instr);
  errLog(disasm);
}
output
"push rbp"
"mov rbp,rsp"
"movsd [rbp-8],xmm0"
"movsd xmm1,[rbp-8]"
"movsd xmm0,[rel 0E77h]"   # load 6067
"addsd xmm0,xmm1"          # add
"movsd [rbp-8],xmm0"
"movsd xmm0,[rbp-8]"
"movsd xmm1,[rel 0E7Fh]"   # load 14
"divsd xmm0,xmm1"          # divide
"movsd [rbp-8],xmm0"
"movsd xmm0,[rbp-8]"
"movsd xmm1,[rel 0E87h]"   # load 16
"divsd xmm0,xmm1"          # divide
[...]
"movsd [rbp-8],xmm0"
"movsd xmm0,[rbp-8]"
"movsd xmm1,[rel 0ECFh]"   # load 203
"subsd xmm0,xmm1"          # subtract
"movsd [rbp-8],xmm0"
"movsd xmm0,[rbp-8]"
"movsd xmm1,[rel 0EB7h]"   # load 6
"subsd xmm0,xmm1"          # subtract
"movsd [rbp-8],xmm0"
"movsd xmm0,[rbp-8]"
"ucomisd xmm0,[rel 0ED7h]"
"setnp al"
"mov edx,0"
"movsd xmm0,[rbp-8]"
"ucomisd xmm0,[rel 0ED7h]" # compare 729
"cmovne eax,edx"
"pop rbp"
"ret"

With this, we can recover what operations are done on the user’s input by identifying addsd, subsd and divsd. To find which constants are used, we can pay attention to the relative memory loads and offset them by the correct amount such that 0x0E77 corresponds to the first value in rodataDoubles. 1

Extract operations
ts
const operations: [string, number][] = [];
let checkValue = -1;
let nextY = -1;

for (const instructionIdx in instructions) {
  const instr = instructions[instructionIdx];
  const disasm = formatter.format(instr);

  const relativeMemoryLoadMatch = /xmm[01],\[rel ([0-9A-F]{4})h\]/g.exec(
    disasm
  );
  if (!!relativeMemoryLoadMatch) {
    const operationIdx = operations.length;
    const nextYIdx = (parseInt(relativeMemoryLoadMatch[1], 16) - 0xe77) / 8;
    nextY = rodataDoubles[nextYIdx];
  }

  const operationMatch = /(add|sub|div)sd/g.exec(disasm);
  if (!!operationMatch) {
    switch (operationMatch[1]) {
      case "add":
        operations.push(["+", nextY]);
        break;
      case "sub":
        operations.push(["-", nextY]);
        break;
      case "div":
        operations.push(["/", nextY]);
        break;
    }
  }
}
checkValue = rodataDoubles[rodataDoubles.length - 1];

errLog("operations:");
errLog(operations);
errLog("checkValue:");
errLog(checkValue);
output
"operations:"
[
  [ "+", 6067 ],  [ "/", 14 ],
  [ "/", 16 ],    [ "+", 3111 ],
  [ "+", 9812 ],  [ "/", 14 ],
  [ "/", 4 ],     [ "+", 2449 ],
  [ "+", 16373 ], [ "/", 16 ],
  [ "/", 6 ],     [ "/", 10 ],
  [ "+", 673 ],   [ "-", 203 ],
  [ "-", 6 ]
]
"checkValue:"
729

Finally, a valid key can be regenerated using the same method as in Hyde Street.

Keygen
ts
let current = checkValue;
operations.reverse();
for (const [op, y] of operations) {
  switch (op) {
    case "+":
      current -= y;
      break;
    case "-":
      current += y;
      break;
    case "/":
      current *= y;
      break;
  }
}
console.log(current);
output
2952189613

The complete script can then be uploaded to the judge system to claim the flag.

Helper script localrun.sh
bash
#!/usr/bin/env bash
image_name=so-above:challenge
submission_path=$(pwd)/submission.ts
container_name=$(docker create --rm --env "FLAG=${flag}" --env "SEED=${seed}" $image_name)
docker cp "$submission_path" "$container_name:/submission.ts"
docker start --attach "$container_name"
Complete solver script submission.ts
ts
import iced from "npm:iced-x86@1.18.0";
import elfinfo from "npm:elfinfo@0.4.0-beta";
import predicates from "npm:@tool-belt/type-predicates@1.2.2";

// Helper functions
const decoder = new TextDecoder();
const dec = (t: Uint8Array) => decoder.decode(t);
const encoder = new TextEncoder();
const enc = (t: string) => encoder.encode(t);
const errLog = (t: Object) =>
  Deno.stderr.writeSync(enc(Deno.inspect(t) + "\n"));

// For server
const binary = await Deno.readFile("/chall/challs/generated");
// For local testing
// const binary = await Deno.readFile('./leak-challs/generated');
const elfdata = await elfinfo.open(binary);
// errLog(Object.keys(elfdata.elf));

const sections = elfdata.elf?.sections as elfinfo.ELFSection[];
const symtab = sections.find((x) => x.name === ".symtab") as elfinfo.ELFSection;
const rodata = sections.find((x) => x.name === ".rodata") as elfinfo.ELFSection;
// errLog(Object.keys(symtab));

const rodataCode = binary.slice(
  Number(rodata.addr) + 0x40,
  Number(rodata.addr) + rodata.size
);
const rodataBuf = new ArrayBuffer(rodataCode.length);
const rodataBytes = new Uint8Array(rodataBuf);
const rodataDoubles = new Float64Array(rodataBuf);
rodataCode.forEach((x, i) => (rodataBytes[i] = x));
// errLog(rodataDoubles);

const quickmathsSym = (symtab as any).symbols.find(
  (x) => x.name === "quick_maths"
);
const quickmathsCode = binary.slice(
  Number(quickmathsSym.value),
  Number(quickmathsSym.value) + quickmathsSym.size
) as Uint8Array;

const instrDecoder = new iced.Decoder(
  64,
  quickmathsCode,
  iced.DecoderOptions.None
);
const instructions = instrDecoder.decodeAll();
const formatter = new iced.Formatter(iced.FormatterSyntax.Nasm);

const operations: [string, number][] = [];
let checkValue = -1;
let nextY = -1;

for (const instructionIdx in instructions) {
  const instr = instructions[instructionIdx];
  const disasm = formatter.format(instr);
  // errLog(disasm);

  const relativeMemoryLoadMatch = /xmm[01],\[rel ([0-9A-F]{4})h\]/g.exec(
    disasm
  );
  if (!!relativeMemoryLoadMatch) {
    const operationIdx = operations.length;
    const nextYIdx = (parseInt(relativeMemoryLoadMatch[1], 16) - 0xe77) / 8;
    nextY = rodataDoubles[nextYIdx];
  }

  const operationMatch = /(add|sub|div)sd/g.exec(disasm);
  if (!!operationMatch) {
    switch (operationMatch[1]) {
      case "add":
        operations.push(["+", nextY]);
        break;
      case "sub":
        operations.push(["-", nextY]);
        break;
      case "div":
        operations.push(["/", nextY]);
        break;
    }
  }
}
checkValue = rodataDoubles[rodataDoubles.length - 1];

errLog("operations:");
errLog(operations);
errLog("checkValue:");
errLog(checkValue);

let current = checkValue;
operations.reverse();
for (const [op, y] of operations) {
  switch (op) {
    case "+":
      current -= y;
      break;
    case "-":
      current += y;
      break;
    case "/":
      current *= y;
      break;
  }
}
console.log(current);
output
# On server
"operations:"
[
  [ "-", 132 ],  [ "/", 8 ],
  [ "/", 4 ],    [ "+", 1185 ],
  [ "/", 5 ],    [ "-", 202 ],
  [ "-", 19 ],   [ "/", 2 ],
  [ "-", 237 ],  [ "-", 204 ],
  [ "-", 153 ],  [ "/", 3 ],
  [ "-", 186 ],  [ "/", 4 ],
  [ "+", 4414 ]
]
"checkValue:"
217335
817982852
false
enter decimal number:
cool :)
"operations:"
[
  [ "-", 130 ], [ "+", 8505 ],
  [ "/", 13 ],  [ "/", 6 ],
[...]
  [ "-", 237 ],   [ "+", 11134 ],
  [ "+", 1930 ]
]
"checkValue:"
13457292
3871095309
false
enter decimal number:
cool :)
flag{alpha659841tango4:GNWPXHFLabb97-wi2fU8mg0RMv1-7r1lJZWMy78bFq1Me0PSOizseFDCvYvW6pnKCabPpPHhxNaeD0KviLD_HSU}

Footnotes

  1. A more elegant way would be to properly extract the .text sections and make use of the iced.InstructionInfoFactory class which can calculate this offset for you, but this worked at the time.