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
.
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.
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
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)
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
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())
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
plaintextcontainer_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.
$ 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
#!/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
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);
# 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
plaintextcontainer_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.
$ 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
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
#!/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
// 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);
# 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
plaintextcontainer_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
.
$ 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
andelfinfo
- The solver script only has access to the compiled binary and cannot read
quick_maths.c
anymore
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
#!/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:
#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
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));
[ "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
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);
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
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); }
"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
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);
"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
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);
2952189613
The complete script can then be uploaded to the judge system to claim the flag.
Helper script localrun.sh
#!/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
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);
# 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
-
A more elegant way would be to properly extract the
.text
sections and make use of theiced.InstructionInfoFactory
class which can calculate this offset for you, but this worked at the time. ↩