Logo
Overview

CoRCTF 2025 Rev Write Up

August 31, 2025
22 min read
CoR CTF REV Write Ups 262bcc8ed7ef80b1beb1fe81ada9b7b6

CoRCTF 2025 Rev Write Up

Challenges

  • rev/tagme
  • rev/purely-functional-oop
  • rev/roll
  • rev/whatever-floats-your-boat

Tagme

Description:

tag, you’re it! g, you’re it!tag Update: the flag does not contain the letter ‘p’

Download: tagme

Summary:

  • General idea for this challenge is you have a flag checker that works by taking in an input, growing the flag via an expansion look up table, finally the flag is checked to the same enqueue point as dequeue. There are several parts to this challenge that make it interesting lets start with the main function:
void __fastcall __noreturn main(int a1, char **a2, char **a3)
{
char c; // [rsp+3h] [rbp-3Dh]
char *lineptr; // [rsp+8h] [rbp-38h] BYREF
size_t n; // [rsp+10h] [rbp-30h] BYREF
unsigned __int64 i; // [rsp+18h] [rbp-28h]
__ssize_t v7; // [rsp+20h] [rbp-20h]
char *flag; // [rsp+28h] [rbp-18h]
unsigned __int64 v9; // [rsp+30h] [rbp-10h]
unsigned __int64 v10; // [rsp+38h] [rbp-8h]
v10 = __readfsqword(0x28u);
puts("Enter flag:");
lineptr = 0;
n = 0;
v7 = getline(&lineptr, &n, stdin);
if ( v7 == -1 )
print("Illiterate");
if ( v7 <= 8 )
print("Short");
if ( v7 > 39 )
print("Long");
if ( strncmp("corctf{", lineptr, 7u) )
print("Ineligible");
if ( strncmp("}\n", &lineptr[v7 - 2], 2u) )
print("Ineligible");
set_up();
flag = lineptr + 7;
v9 = v7 - 9;
for ( i = 0; i < v9; ++i )
{
c = flag[i];
if ( (i & 1) != 0 )
{
if ( i % 6 > 3 )
{
if ( c <= 'p' )
print("Forbidden");
}
else if ( c <= 'k' || c == 's' )
{
print("Forbidden");
}
}
else if ( c == 'c' || c > 'l' )
{
print("Forbidden");
}
enque(c);
}
while ( !(unsigned int)final_check() )
;
print("Boring");
}
  • The very first thing we see is the size of our flag has to be between 8 and 39. Starts with corctf{ , and ends with } all standard. From there we have have a loop that will take our flag after the flag format with several checks:
    • First we break up the code by odd or evens using (i & 1) != 0 from there we have an extra check to see if the character is odd and at an index greater then 3, the character p is not allowed and if its less then or equal to 3 it must be a character greater than k and not s.
    • Second the even indices can’t be c or greater then l

This leads us to the renamed final_check function:

  • before we get to this function we should check out 2 key functions that do a bit of setup for the final_check

setup()

void *set_up()
{
read = 0;
write = 0;
return memset(flag_buff, 0, sizeof(flag_buff)); //this is 4600 in size
}
  • This function is allocating the buffer for our custom data structure used for flag expansion

enque()

__int64 __fastcall enque(char c)
{
__int64 result; // rax
flag_buff[write] = c;
if ( ++write == 4600 ) // wrap around
write = 0;
result = read;
if ( write == read )
pass("Interesting");
return result;
}
  • Now this gets to the core of the code base, our entire custom buffer is more or less a ring queue, which in plain english means we can enqueue/dequeue like a normal queue but we also will wrap around when we pass the 4600 buff limit. Here we also have the pass case for our code if we are enqueueing the code and it has the same location as the read we are done.

Now lets move on to final check:

__int64 final_check()
{
size_t len; // rax
char val; // [rsp+Ah] [rbp-26h]
char val2; // [rsp+Bh] [rbp-25h]
int i; // [rsp+Ch] [rbp-24h]
size_t j; // [rsp+10h] [rbp-20h]
__int64 idx; // [rsp+18h] [rbp-18h]
char *new_val; // [rsp+28h] [rbp-8h]
if ( queue_emppty() )
return 1;
val = deque();
idx = look_up(val); // returns the tabel's index here
if ( idx == -1 ) // look up failed
print("Bizzare");
for ( i = 1; i <= 1; ++i )
{
if ( queue_emppty() )
return 1;
val2 = deque();
if ( look_up(val2) == -1 )
print("Bizzarre");
}
new_val = (char *)*((_QWORD *)&flag_mappings + 2 * idx + 1);
j = 0;
for ( len = strlen(new_val); j < len; len = strlen(new_val) )
enque(new_val[j++]); // per char
return 0;
}
  • The queue_empty checks are pretty straight forward they are there to see if you fucked up

Now lets go on to our deque() logic:

__int64 deque()
{
unsigned __int8 c; // [rsp+Fh] [rbp-1h]
if ( read == write )
fail("Dry");
c = flag_buff[read];
if ( ++read == 4600 ) // read location
read = 0; // wrap around
return c;
}
  • pretty standard, same idea really we read from the start like a normal queue if we ever hit the end we will wrap around. Our fail case here is if the read and write ptrs are at the same location during the dequeue process.

Now on to the look up function look_up()

__int64 __fastcall look_up(char a1)
{
unsigned __int64 i; // [rsp+Ch] [rbp-8h]
for ( i = 0; i <= 9; ++i ) // 10 mappings
{
if ( a1 == *((_BYTE *)&flag_mappings + 16 * i) )
return i;
}
return -1;
}
flag_mappings = {
'a': "ns",
'b': "jjj",
'n': "a",
'd': "q",
'p': "cor",
'f': "gg",
's': "aaa",
'e': "tt",
'j': "gg",
'c': "flag",
}
  • The look up data struct is fairly simple, there are 10 mappings, the first character of every mapping is our “key”/input var. The mappings as such then start from the 8th byte and vary in size.
    • The look up function doesn’t give back the mapping just gives you the idx of the key char you want to look use.

Going back to the final check logic:

for ( i = 1; i <= 1; ++i )
{
if ( queue_emppty() )
return 1;
val2 = deque();
if ( look_up(val2) == -1 )
print("Bizzarre");
}
  • This function will result in the code wasting the second character, it’s never added back but it’s checked for validity

Finally our mapping is added back into the code base:

new_val = (char *)*((_QWORD *)&flag_mappings + 2 * idx + 1);
j = 0;
for ( len = strlen(new_val); j < len; len = strlen(new_val) )
enque(new_val[j++]);
return 0;
  • Alright now what exactly do we need to actually pass this flag checker then?
    1. write == read during the enqueue
    2. write ≠ read during the dequeue
    3. we must be valid in the char index checks
      1. odd index > 3
        1. p banned
      2. odd index ≤ 3
        1. char > k and not s
      3. even index
        1. char > l and not c

If we just simulate/brute force this:

CAP = 4600
MAPPING = {
'a': "ns",
'b': "jjj",
'n': "a",
'd': "q",
'p': "cor",
'f': "gg",
's': "aaa",
'e': "tt",
'j': "gg",
'c': "flag",
}
class Ring:
def __init__(self, cap):
self.buf = ['\x00'] * cap
self.read = 0
self.write = 0
self.cap = cap
self.size = 0
self.done = False
def enqueue(self, ch):
self.buf[self.write] = ch
self.write += 1
if self.write == self.cap:
self.write = 0
self.size += 1
if self.write == self.read:
print("solved")
self.done = True
def empty(self):
return self.size == 0
def dequeue(self):
if self.empty():
raise RuntimeError("Dry")
v = self.buf[self.read]
self.read += 1
if self.read == self.cap:
self.read = 0
self.size -= 1
return v
SYMS = list(MAPPING.keys())
def look_up(ch):
try:
return SYMS.index(ch)
except ValueError:
return -1
def final_check_step(ring):
if ring.empty():
return 1
val = ring.dequeue()
idx = look_up(val)
s = MAPPING[SYMS[idx]] if idx != -1 else None
if ring.empty():
return 1
_ = ring.dequeue()
if s:
for ch in s:
ring.enqueue(ch)
return 0
def flag_seed():
out = []
for i in range(27):
if i % 2 == 0:
out.append('a')
else:
if i % 6 > 3:
out.append('s')
else:
out.append('n')
return ''.join(out)
def find(seed_inner, report_every=100, max_steps=10_000_000):
ring = Ring(CAP)
for ch in seed_inner:
ring.enqueue(ch)
steps = 0
while ring.done == False and steps < max_steps:
final_check_step(ring)
steps += 1
return ring.done, steps
def main():
seed = flag_seed()
ok, steps = find(seed)
if ok:
print("full input", "corctf{" + seed + "}")
else:
print("did not solve")
if __name__ == "__main__":
main()

Purely-functional-oop

Description:

The purest programming language you’ve ever seen, transpiling into google sheets!

Download: chall.tar.gz

Summary:

  • General idea for this challenge is you are given a custom programming language compiler, it compiles a fairly simple object oriented programming language into the functional programming language format of google sheets.

The challenge ended up having an unintended solve method that was far easier than this and had to do with Google Sheets type confusion. Given the fact that the exploit code is 2 lines, I am going to stick with the intended solution for this write up.

The challenge is to somehow pass the impossible Fermat’s Last Theorem ie a3+b3=c3a^3+b^3 = c^3

CHALL_CODE = """
fn _pow(curr_val, exp_base, exponent) {
let done = exponent.equals(0);
return done ? curr_val : _pow(curr_val.times(exp_base), exp_base, exponent.minus(1));
}
fn pow(exp_base, exponent) {
return _pow(1, exp_base, exponent);
}
fn nonzero(x) {
return x.notEquals(0).and(0.notEquals(x));
}
fn isInteger(x) {
let sign_val = x.greaterThan(0) ? 1 : -1;
let absVal = sign_val.times(x);
let exactlyZero = absVal.equals(0);
let isDecimal = absVal.greaterThan(0).and(absVal.lessThan(1));
return exactlyZero ? true : (isDecimal ? false : isInteger(absVal.minus(1)));
}
class Challenge {
constructor() {
let this.n = 3;
let this.flag = """ + f'"{FLAG}"' + """;
}
fn verifySolution(a, b, c) {
let nonzeroInput = nonzero(a).and(nonzero(b)).and(nonzero(c));
let integerInput = isInteger(a).and(isInteger(b)).and(isInteger(c));
let satisfiesTheorem = pow(a, this.n).plus(pow(b, this.n)).equals(pow(c, this.n));
let accepted = nonzeroInput.and(integerInput).and(satisfiesTheorem);
return accepted ? this.flag : "Solution rejected";
}
}
"""
  • This also gives us a look into the actual format of the custom programming language, we can see how its OOP in nature given the fact you need a constructor and classes to write code.
    • the only other interesting part of this checker is the build order:
challenge_code = CHALL_CODE + str(user_code)
compiled_code = compile_code(challenge_code)
result = execute_formula(compiled_code)
  • our code is added after the challenge code

For the sake of everyone’s sanity I will not be pasting in the entire compiler but let’s go ahead and pick the interesting parts for the intended exploit. The first thing that should catch your eye and probably AI’s as a bug in the code base is the definition of greaterThanOrEquals being incorrect. This takes place while the code is defining the default functions the user has at their disposal.

compiled_result = f"""
=LET(
_raise_error_internal, LAMBDA(LAMBDA(a, a)("illegal", "syntax")),
_message_match, LAMBDA(expected, LAMBDA(actual,
IFERROR((actual = expected), FALSE)
)),
make_builtin_bootstrap, LAMBDA(f, LAMBDA(raw,
LAMBDA(_message,
IF(_message_match("_rawVal")(_message),
raw,
IF(_message_match("plus")(_message),
LAMBDA(rhs, f(f)(raw + rhs("_rawVal"))),
IF(_message_match("minus")(_message),
LAMBDA(rhs, f(f)(raw - rhs("_rawVal"))),
IF(_message_match("times")(_message),
LAMBDA(rhs, f(f)(raw * rhs("_rawVal"))),
IF(_message_match("divide")(_message),
LAMBDA(rhs, f(f)(raw / rhs("_rawVal"))),
IF(_message_match("equals")(_message),
LAMBDA(rhs, f(f)(raw = rhs("_rawVal"))),
IF(_message_match("notEquals")(_message),
LAMBDA(rhs, f(f)(raw <> rhs("_rawVal"))),
IF(_message_match("and")(_message),
LAMBDA(rhs, f(f)(AND(raw, rhs("_rawVal")))),
IF(_message_match("or")(_message),
LAMBDA(rhs, f(f)(OR(raw, rhs("_rawVal")))),
IF(_message_match("xor")(_message),
LAMBDA(rhs, f(f)(XOR(raw, rhs("_rawVal")))),
IF(_message_match("lessThan")(_message),
LAMBDA(rhs, f(f)(raw < rhs("_rawVal"))),
IF(_message_match("lessThanOrEquals")(_message),
LAMBDA(rhs, f(f)(raw <= rhs("_rawVal"))),
IF(_message_match("greaterThan")(_message),
LAMBDA(rhs, f(f)(raw > rhs("_rawVal"))),
IF(_message_match("greaterThanOrEquals")(_message),
LAMBDA(rhs, f(f)(raw <= rhs("_rawVal"))),
IF(_message_match("negate")(_message),
f(f)(NOT(raw)),
IF(_message_match("factorial")(_message),
f(f)(FACT(raw)),
IF(_message_match("log")(_message),
f(f)(LOG(raw)),
IF(_message_match("concat")(_message),
LAMBDA(rhs, f(f)(CONCAT(raw, rhs("_rawVal")))),
_raise_error_internal()
))))))))))))))))))
)
)),
make_builtin, (make_builtin_bootstrap) (make_builtin_bootstrap),
{program_code}("_rawVal")
)
"""
return compiled_result
  • By itself this bug doesn’t do a whole lot for us this conditional isn’t actually used in the check and it being wrong mean little to us. That means we have to some how combine it into a meaningful exploit

Anyway lets move on to the next intersting part of the code base:

RESERVED_KEYWORDS = { "class", "fn", "constructor", "let", "return", "new", "this", "true", "false", "_f", "_message", "_constructor", "_message_match", "_raise_error_internal" }
def ensure_valid_name(variable_name, allow_this=False):
# make sure the variable name is not a spreadsheet cell reference (e.g. "A1", "AA12", etc.)
# using regex to elimiate these invalid names of [a-zA-Z]+[0-9]+, which is the valid identifier pattern
if re.match(r"^[a-zA-Z]+[0-9]+$", variable_name):
raise SyntaxError(f"Invalid symbol name matches cell ID, try adding an underscore: {variable_name}")
if variable_name == "this":
if not allow_this:
raise SyntaxError("The 'this' keyword can only be used in class definitions")
elif variable_name in RESERVED_KEYWORDS:
raise SyntaxError(f"Variable name matches reserved keyword: {variable_name}")
return variable_name
def ensure_statement_node(statement_node):
if statement_node.data != "statement":
raise SyntaxError(f"Expected 'statement', got {statement_node.data}")
return statement_node
  • Given the fact we are in google sheets at the end of the day if we can call out of the programming language that obviously means we can do things we aren’t allowed to and because of that there is an imposed filter system in the compiler that prevents certain words and values from being used. This includes keywords in the programming language that could break the compiler as well as values to access the google sheets cells.
  • While this seems pretty strong at first glance a pretty interesting compiler primitive is left in that we are able to use the "_rawVal" tag is seemingly forgotten in the restrictions. The other key tag that is forgotten here is the and function call which we are able to redefine as well.

What actually is _rawVal?

  • given its usage in the generation of the programming language primitives we get a good idea what it actually ends up being used for:
IF(_message_match("plus")(_message),
LAMBDA(rhs, f(f)(raw + rhs("_rawVal"))),
  • here we can get a pretty good idea its used to actually query the raw google sheets value of an object in the custom programming language.

Lets check one more example really quick:

elif expression_type == "ternary_expression":
condition = parse_expression(expression_node.children[0], scope, outer_name)
true_branch = parse_expression(expression_node.children[1], scope, outer_name)
false_branch = parse_expression(expression_node.children[2], scope, outer_name)
return f"IF({condition}(\"_rawVal\"), {true_branch}, {false_branch})"
  • if we compile the following:
PAYLOAD = r'''
let x = 1;
let result = x.greaterThan(5) ? "big" : "small";
return x.notEquals(0);
'''

we get the following compiler result:

IF(accepted("_rawVal"), this_flag, make_builtin("Solution rejected"))
)
),
_raise_error_internal())
)
))
)),
bootstrap_new_Challenge, LAMBDA(_f, LAMBDA( class_Challenge(LAMBDA( (LAMBDA(_message, _f(_f)()(_message))))) (LAMBDA(_message, _f(_f)()(_message))) ())),
new_Challenge, (bootstrap_new_Challenge) (bootstrap_new_Challenge),
x, make_builtin(1),
result, IF(x("greaterThan")(make_builtin(5))("_rawVal"), make_builtin("big"), make_builtin("small")),
x("notEquals")(make_builtin(0))
("_rawVal")
  • Here we can see that the conditional check using the IF google sheets command requires _rawVal again this because it’s used to bring the logic down the google sheets level.

Well with all of that out of the way how can we actually exploit the check? Well lets think about this, what exactly is preventing us from passing the check atm

  1. no real number other then 0 will ever pass it
  2. we can’t pass in zero

This means our goal more or less is to somehow get the 0 value into the system and we win. How do we do that? well we use the fact we have access to using a primitive we shouldn’t have access to allow us to pass in zero.

class Truth {
constructor() { }
fn and(rhs) {
return new Truth();
}
fn _rawVal() {
return true._rawVal();
}
}
class Sneaky {
constructor() { }
fn notEquals(rhs) {
return new Truth();
}
fn greaterThan(rhs) {
return new Truth();
}
fn _rawVal() {
return 0._rawVal();
}
}
let ch = new Challenge();
return ch.verifySolution(new Sneaky(), 0, 0);

Lets go ahead and break this down

  • Our Truth class does two things for us
    • we are able to define it as always being true, how exactly does this work? well since we are able to redefine the _rawVal() per class given its not restricted we just say that whenever _rawVal is called on this object we return true.
    • next we need to see how exactly the check works:
fn verifySolution(a, b, c) {
let nonzeroInput = nonzero(a).and(nonzero(b)).and(nonzero(c));
let integerInput = isInteger(a).and(isInteger(b)).and(isInteger(c));
let satisfiesTheorem = pow(a, this.n).plus(pow(b, this.n)).equals(pow(c, this.n));
let accepted = nonzeroInput.and(integerInput).and(satisfiesTheorem);
return accepted ? this.flag : "Solution rejected";
}
  • So what exactly does our check here do? lets break it down we are going to pass in the a value and then do an and chain against the other values. This is then used to validate everything finally by anding the results to check if everything is positive.
  • So this explains the second part of our Truth class by causing the and value to always equal back to Truth() during an and allows us to cause the code to forcibly result in everything returning true.

Now we get on to the Sneaky Class here we are actually tricking the integerInput, nonzeroInput and satisfiesTheorem custom functions that the solver class ends up defining. Remember if we can pass this on our first input we use the fake and to chain truth allowing us to win.

fn _pow(curr_val, exp_base, exponent) {
let done = exponent.equals(0);
return done ? curr_val : _pow(curr_val.times(exp_base), exp_base, exponent.minus(1));
}
fn pow(exp_base, exponent) {
return _pow(1, exp_base, exponent);
}
fn nonzero(x) {
return x.notEquals(0).and(0.notEquals(x));
}
fn isInteger(x) {
let sign_val = x.greaterThan(0) ? 1 : -1;
let absVal = sign_val.times(x);
let exactlyZero = absVal.equals(0);
let isDecimal = absVal.greaterThan(0).and(absVal.lessThan(1));
return exactlyZero ? true : (isDecimal ? false : isInteger(absVal.minus(1)));
}
  • isInteger
    • we need to pass greaterThan check
  • nonzero
    • we need to pass notEquals check
  • pow
    • we need the actual numeric value of the object to equal 0 when called
      • ie _rawVal

When we combine this all together we get our exploit, a single class that is able to trick the code base into thinking the internal checks are true, and after doing so will result in an and truth chaining to pass the final verfication check which is also an and truth chain xD

Roll

Description:

Roll with the flow wait that’s not the right phrase…

Download: roll_vm and program1.txt

Summary:

  • we love vms we love vms we love vms
    • Tldr: this is a vm, it does vm things, the vm rolls its register/ie behaves in a self modifying manner, the vm has an inf loop at the start !

Lets go through the code base, honestly everything happens in this single function:

__int64 __fastcall MAIN(int argc, __int64 argv, __int64 a3, __int64 a4, __int64 a5, __int64 a6)
{
unsigned __int64 ip; // rbx
__int128 *v8; // rbp
bool v9; // cf
__int64 v10; // rax
__int64 v11; // rax
__m128d v12; // xmm0
__int64 v13; // rbx
int cnt; // ebx
int cnt_2; // ebx
if ( argc <= 1 )
{
print("Expected filename as first arg");
vm_exit(1);
}
if ( argc == 10 )
{
cnt = 10;
do
{
vm_fake(2, (__int64)"corctf{", a3, a4, a5, a6);
--cnt;
}
while ( cnt );
cnt_2 = 10;
print("corctf 2025 easter egg");
do
{
print("ragebait");
--cnt_2;
}
while ( cnt_2 );
start:
vm_exit(1);
}
vm_load_program(*(_QWORD *)(argv + 8));
vm_init_state();
ip = r1;
if ( (unsigned __int64)r1 >= program_len )
{
error:
print("VM error");
goto start;
}
while ( 2 )
{
switch ( *(_BYTE *)(data + ip) )
{
case 0:
v8 = &r1;
j_ifunc_415100(&r1, (char *)&r1 + 8, 24);
*(_QWORD *)&r1 = r1 - 1;
*((_QWORD *)&r2 + 1) = ip;
goto dealloc;
case 1:
v13 = *((_QWORD *)&r2 + 1);
v8 = &r1;
j_ifunc_415100((char *)&r1 + 8, &r1, 24);
*(_QWORD *)&r1 = v13;
goto dealloc;
case 2:
v12 = (__m128d)_mm_loadu_si128((const __m128i *)((char *)&r1 + 8));
v8 = &r1;
*(__int128 *)((char *)&r1 + 8) = (__int128)_mm_shuffle_pd(v12, v12, 1);
goto dealloc;
case 3:
v11 = *((_QWORD *)&r1 + 1);
v8 = &r1;
*((_QWORD *)&r1 + 1) = *((_QWORD *)&r2 + 1);
*((_QWORD *)&r2 + 1) = v11;
goto dealloc;
case 4:
v8 = &r1;
*((_QWORD *)&r1 + 1) = r2;
goto dealloc;
case 5:
v8 = &r1;
*((_QWORD *)&r1 + 1) += r2;
goto dealloc;
case 6:
v8 = &r1;
*((_QWORD *)&r1 + 1) -= r2;
goto dealloc;
case 7:
v8 = &r1;
*((_QWORD *)&r1 + 1) *= (_QWORD)r2;
goto dealloc;
case 8:
v8 = &r1;
*((_QWORD *)&r1 + 1) /= (unsigned __int64)r2;
goto dealloc;
case 9:
v8 = &r1;
*((_QWORD *)&r1 + 1) %= (unsigned __int64)r2;
goto dealloc;
case 0xA:
v8 = &r1;
*((_QWORD *)&r1 + 1) = 0;
goto dealloc;
case 0xB:
++*((_QWORD *)&r1 + 1);
v8 = &r1;
goto dealloc;
case 0xC:
--*((_QWORD *)&r1 + 1);
v8 = &r1;
goto dealloc;
case 0xD:
v10 = qword_4ACB30;
if ( (unsigned __int64)qword_4ACB30 > 0x7FF )
{
print("Stack overflow");
vm_exit(1);
}
v8 = &r1;
++qword_4ACB30;
*(_QWORD *)(vm_stack_mem + 8 * v10) = *((_QWORD *)&r1 + 1);
goto dealloc;
case 0xE:
if ( !qword_4ACB30 )
{
print("Stack underflow");
vm_exit(1);
}
v8 = &r1;
--qword_4ACB30;
*((_QWORD *)&r1 + 1) = *(_QWORD *)(vm_stack_mem + 8 * qword_4ACB30);
goto dealloc;
case 0xF:
v8 = &r1;
*((_QWORD *)&r1 + 1) = (int)fgetc((__int64)off_4AB6D8);
goto dealloc;
case 0x10:
sub_407E40((unsigned int)SBYTE8(r1), off_4AB6D0);
goto LABEL_7;
case 0x11:
LABEL_7:
v8 = &r1;
dealloc:
ip = *(_QWORD *)v8 + 1LL;
v9 = ip < program_len;
*(_QWORD *)v8 = ip;
if ( !v9 )
goto error;
continue;
case 0x12: // s - HALT
free(data);
free(vm_stack_mem);
return 0;
}
}
}
  • This function is the primary iterator which ends up doing all of the op-code level logic but before the iterator in the function we actually have the VM init logic as well as the iterator into the program code via the fake programming language. This is mostly just so our code base can understand the fake programming language layer ie all the letters in the input file:
vm2 % xxd program1.txt
00000000: 6c6e 636b 6d63 696e 6b6e 6c63 6f66 6e6b lnckmcinknlcofnk
00000010: 6c6c 6863 6f66 6e6b 6c6c 6863 6f66 6e6b llhcofnkllhcofnk
00000020: 6c6c 6863 6f66 6e6b 6c6c 6863 6f63 656d llhcofnkllhcocem
00000030: 6d6d 6d6d 6863 6f68 636f 6361 7372 6c73 mmmmhcohcocasrls
00000040: 7366 6b6d 6c6a 6d67 6c64 6e6d 6371 6c6d sfkmljmgldnmcqlm
00000050: 6d6a 616c 646f 6866 6b6f 6967 7263 7173 mjaldohfkoigrcqs
00000060: 6263 6e62 6167 716c 7162 6764 6a67 6c6c bcnbagqlqbgdjgll
00000070: 6c6c 6c6c 6c6c 716d 6d6d 6d6d 6d63 6561 llllllqmmmmmmcea
00000080: 626f 736b 616b 6e73 6872 6272 6a67 6671 boskaknshrbrjgfq
00000090: 7171 6662 7073 6b6e 6c63 6b6c 6c68 636b qqfbpsknlckllhck
000000a0: 6c6c 6863 6b6c 6c68 636f 666e 6b6c 6c68 llhckllhcofnkllh
000000b0: 636f 666e 6b6c 6c68 636b 6c6c 6863 6f66 cofnkllhckllhcof
000000c0: 6e6b 6c6c 6863 6f66 6e6b 6c6c 6863 6f66 nkllhcofnkllhcof
000000d0: 6e6b 6c6c 6863 6f66 6e6b 6c6c 6863 6f66 nkllhcofnkllhcof

This ends up getting interpreted into the switch case program counter via the following function:

__int64 __fastcall vm_load_program(__int64 file)
{
__int64 data_ptr; // rax
__int64 file_ptr; // rbp
__int64 final_post; // rbx
__int64 v4; // rax
char *v5; // r12
char *p; // rdx
__int64 decode_len; // rcx
unsigned int val; // eax
data_ptr = read(file, "rb");
if ( !data_ptr )
{
print("Failed to open file");
vm_exit(1);
}
file_ptr = data_ptr;
((void (__fastcall *)(__int64, _QWORD, __int64))file_seek_locked)(data_ptr, 0, 2);
final_post = ((__int64 (__fastcall *)(__int64))ftell)(file_ptr);
((void (__fastcall *)(__int64))flush_reset)(file_ptr);
v4 = sub_413180(final_post);
v5 = (char *)v4;
if ( !v4
|| (sub_405990(v4, 1, final_post, file_ptr), program_len = final_post, (program = calloc(final_post, 1)) == 0) )
{
print("Failed to allocate required data");
vm_exit(1);
}
if ( final_post <= 0 )
{
decode_len = 0;
}
else
{
p = v5;
decode_len = 0;
do
{
val = *p - 'a';
if ( val <= 0x12 )
*(_BYTE *)(program + decode_len++) = val;
++p;
}
while ( &v5[final_post] != p );
}
program_len = decode_len;
return free(v5);
}
  • The code here ends up being stored in the global var program
    • we can see how it does a simple set up for the numeric case swaps by subtracting a .

Now its time to make a quick mapping for all the opcodes (standard VM practice):

OPCODES = [
("CALL", "a"), #0
("RET", "b"),
("SWAP_AB", "c"),
("SWAP_AC", "d"),
("MOV_A_B", "e"),
("ADD", "f"),
("SUB", "g"),
("MUL", "h"),
("DIVU", "i"),
("MODU", "j"),
("ZERO", "k"),
("INC", "l"),
("DEC", "m"),
("PUSH", "n"),
("POP", "o"),
("GETC", "p"),
("PUTC", "q"),
("NOP", "r"),
("HALT", "s"), # 18
]
  • if you want to be really smart 🤓 you can just set up the char mappings using the idx and adding ord(a) but like readability or smth

I ended up making a quick VM interpreter and saw that the code end up running forever. I tried the actual given binary and same story, this crap was running for ages and never going anywhere. Which lead me to make a simple gdb script to check if my vm was the same as remote:

Terminal window
set logging file execution_log.txt
set logging enabled on
echo Logging execution to execution_log.txt\n
# Function to map RAX values to case labels
define map_case
set $rax_val = $arg0
if $rax_val == 0x401c90
printf "Case 0x0\n"
end
if $rax_val == 0x401c65
printf "Case 0x1\n"
end
if $rax_val == 0x401c45
printf "Case 0x2\n"
end
if $rax_val == 0x401c1d
printf "Case 0x3\n"
end
if $rax_val == 0x401c03
printf "Case 0x4\n"
end
if $rax_val == 0x401be9
printf "Case 0x5\n"
end
if $rax_val == 0x401bc8
printf "Case 0x6\n"
end
if $rax_val == 0x401ba6
printf "Case 0x7\n"
end
if $rax_val == 0x401b83
printf "Case 0x8\n"
end
if $rax_val == 0x401b60
printf "Case 0x9\n"
end
if $rax_val == 0x401b4b
printf "Case 0xa\n"
end
if $rax_val == 0x401b37
printf "Case 0xb\n"
end
if $rax_val == 0x401b23
printf "Case 0xc\n"
end
if $rax_val == 0x401ae7
printf "Case 0xd\n"
end
if $rax_val == 0x401ab1
printf "Case 0xe\n"
end
if $rax_val == 0x401a93
printf "Case 0xf\n"
end
if $rax_val == 0x401a48
printf "Case 0x10\n"
end
if $rax_val == 0x401a5b
printf "Case 0x11\n"
end
if $rax_val == 0x401a30
printf "Case 0x12\n"
end
end
# Set breakpoint at jmp rax (0x401a2a)
break *0x401a2a
commands
silent
printf "[RIP=0x%lx] JMP RAX -> 0x%lx, EAX=0x%x, [RDI+RBX]=0x%x\n", $rip, $rax, $eax, *(unsigned char*)($rdi+$rbx)
map_case $rax
continue
end
run

The script is pretty simple I just break point on the jmp rax and see the value there.

image.png

I ended up seeing the same loop that I saw in my VM as well:

Terminal window
INC (l)
PUSH (n)
SWAP_AB (c)
ZERO (k)
DEC (m)
SWAP_AB (c)
DIVU (i)
PUSH (n)
ZERO (k)
PUSH (n)
INC (l)
SWAP_AB (c)
POP (o)
ADD (f)
PUSH (n)
ZERO (k)
INC (l)
INC (l)
MUL (h)
SWAP_AB (c)
POP (o)
ADD (f)
PUSH (n)
ZERO (k)
INC (l)
INC (l)
MUL (h)
SWAP_AB (c)
POP (o)
ADD (f)
PUSH (n)
ZERO (k)
INC (l)
INC (l)
MUL (h)
SWAP_AB (c)
POP (o)
ADD (f)
PUSH (n)
ZERO (k)
INC (l)
INC (l)
MUL (h)
SWAP_AB (c)
POP (o)
SWAP_AB (c)
MOV_A_B (e)
DEC (m)
DEC (m)
DEC (m)
DEC (m)
DEC (m)
MUL (h)
SWAP_AB (c)
POP (o)
MUL (h)
SWAP_AB (c)
POP (o)
SWAP_AB (c)
CALL (a)

This loop actually gives us a pretty big hint on to what the solution for our VM might be. We know that the VM doesn’t take in any actual input, which means the flag has to somehow be entirely self contained. Finally if you look through the opcodes and text at the end you can see that the word “corctf” is pretty clearly spelt out which suggests its building the flag and might print it out at the end.

This leads me to the next part of this making a valid emulator to figure out wtf is going on:

#!/usr/bin/env python3
import sys
U64 = 0xFFFFFFFFFFFFFFFF
STACK_CAP = 0x800
DEBUG = False
OPCODES = [
("CALL", "a"),
("RET", "b"),
("SWAP_AB", "c"),
("SWAP_AC", "d"),
("MOV_A_B", "e"),
("ADD", "f"),
("SUB", "g"),
("MUL", "h"),
("DIVU", "i"),
("MODU", "j"),
("ZERO", "k"),
("INC", "l"),
("DEC", "m"),
("PUSH", "n"),
("POP", "o"),
("GETC", "p"),
("PUTC", "q"),
("NOP", "r"),
("HALT", "s"),
]
OPCODE_MAP = {op[1]: op[0] for op in OPCODES}
LETTER_SET = set(OPCODE_MAP.keys())
def u64(x: int) -> int:
return x & U64
class VM:
def __init__(self, code_bytes):
self.stack = []
self.prog = []
self.pc = 0
self.a = 0
self.b = 0
self.c = 0
self.prog = [b for b in code_bytes if 97 <= b <= 115]
def push(self):
if len(self.stack) >= STACK_CAP:
sys.stdout.write("Stack overflow\n")
sys.exit(1)
self.stack.append(u64(self.a))
def pop(self):
if not self.stack:
sys.stdout.write("Stack underflow\n")
sys.exit(1)
self.a = self.stack.pop()
def run(self):
while 0 <= self.pc < len(self.prog):
op_ch = chr(self.prog[self.pc])
next_pc = self.pc + 1
match OPCODE_MAP[op_ch]:
case "CALL":
if self.pc == 59:
old_pc, old_a, old_b, old_c = self.pc, self.a, self.b, self.c
self.pc, self.a, self.b, self.c = old_a, old_b, old_c, old_pc
next_pc = 150
else:
old_pc, old_a, old_b, old_c = self.pc, self.a, self.b, self.c
self.pc, self.a, self.b, self.c = old_a, old_b, old_c, old_pc
next_pc = self.pc
case "RET":
old_pc, old_a, old_b, old_c = self.pc, self.a, self.b, self.c
self.pc, self.a, self.b, self.c = old_c, old_pc, old_a, old_b
next_pc = self.pc + 1
case "SWAP_AB":
self.a, self.b = self.b, self.a
case "SWAP_AC":
self.a, self.c = self.c, self.a
case "MOV_A_B":
self.a = u64(self.b)
case "ADD":
self.a = u64(self.a + self.b)
case "SUB":
self.a = u64(self.a - self.b)
case "MUL":
self.a = u64(self.a * self.b)
case "DIVU":
bb = u64(self.b)
if bb == 0:
sys.exit("VM error")
self.a = u64(u64(self.a) // bb)
case "MODU":
bb = u64(self.b)
if bb == 0:
sys.exit("VM error")
self.a = u64(u64(self.a) % bb)
case "ZERO":
self.a = 0
case "INC":
self.a = u64(self.a + 1)
case "DEC":
self.a = u64(self.a - 1)
case "PUSH":
self.push()
case "POP":
self.pop()
case "GETC":
b = sys.stdin.buffer.read(1)
self.a = U64 if not b else b[0]
case "PUTC":
sys.stdout.buffer.write(bytes([self.a & 0xFF]))
sys.stdout.buffer.flush()
case "NOP":
pass
case "HALT":
sys.exit(0)
if DEBUG:
print("PC:",self.pc)
print("OPCODE:",OPCODE_MAP[op_ch])
print("REGISTERS:",self.a, self.b, self.c)
print("NEXT PC:",next_pc)
print("STACK:",self.stack,"\n")
# if OPCODE_MAP[op_ch] == "CALL":
# break
self.pc = next_pc
def main():
with open("program1.txt", "rb") as f:
code = f.read()
VM(code).run()
if __name__ == "__main__":
main()

Most of the opcodes are fairly simple what isn’t very simple is the call and ret ie case 0 and 1 lets go through the source code and figure out how it actually works:

00401c90 // call/go to?
00401c90 488d35b1ae0a00 lea rsi, [rel r1+8]
00401c97 ba18000000 mov edx, 0x18
00401c9c 488d6ef8 lea rbp, [rsi-0x8]
00401ca0 4889ef mov rdi, rbp {r1}
00401ca3 e898f3ffff call sub_401040
00401ca8 48832d90ae0a0001 sub qword [rel r1], 0x1
00401cb0 48891da1ae0a00 mov qword [rel r2+8], rbx
00401cb7 e9acfdffff jmp 0x401a68

lets trace the following call: 00401ca3 e898f3ffff call sub_401040

00401040 int64_t sub_401040()
00401040 f30f1efa endbr64
00401044 ff25c69f0a00 jmp qword [rel data_4ab010]

lets follow the jump dynamically, doing that gets us to:

00417d80 f30f1efa endbr64
00417d84 4889f8 mov rax, rdi
00417d87 4883fa20 cmp rdx, 0x20
00417d8b 7233 jb 0x417dc0
00417d8d 62e1fe286f06 vmovdqu64 ymm16, k0, ymmword [rsi]
00417d93 4883fa40 cmp rdx, 0x40
00417d97 0f87a3000000 ja 0x417e40
00417d9d 62e1fe286f4c16ff vmovdqu64 ymm17, k0, ymmword [rsi+rdx-0x20]
00417da5 62e1fe287f07 vmovdqu64 ymmword [rdi], k0, ymm16
00417dab 62e1fe287f4c17ff vmovdqu64 ymmword [rdi+rdx-0x20], k0, ymm17
00417db3 c3 retn {__return_addr}

Well, why is this challenge called roll my dear Watson?

lea rsi, [rel r1+8] ; rsi = &r1.high (source)
mov edx, 0x18 ; Copy 24 bytes
lea rbp, [rsi-0x8] ; rbp = &r1.low
mov rdi, rbp ; rdi = &r1.low (destination)
call sub_401040 ; memcpy(r1, r1+8, 24)
sub qword [rel r1], 0x1 ; r1.low -= 1
mov qword [rel r2+8], rbx ; r2.high = rbx

in english this means:

  • r1.low = r1.high
    • r1 low is the PC/IP here btw
  • r1.high. = r2.low
  • r2.low = r2.high
  • r2.high = r1.low
    • finally the PC is lowered by 1

The “RET” does something similar just check my interpreter for the logic

Finally lets get to the last part of this what the fuck does the operations from before actually end up doing after getting a working emulator set up we get this:

python3 roll_vm_solve.py
PC: 0
OPCODE: INC
REGISTERS: 1 0 0
NEXT PC: 1
STACK: []
PC: 1
OPCODE: PUSH
REGISTERS: 1 0 0
NEXT PC: 2
STACK: [1]
PC: 2
OPCODE: SWAP_AB
REGISTERS: 0 1 0
NEXT PC: 3
STACK: [1]
... big jum p in PC
PC: 52
OPCODE: MUL
REGISTERS: 150 15 0
NEXT PC: 53
STACK: [1, 0]
PC: 53
OPCODE: SWAP_AB
REGISTERS: 15 150 0
NEXT PC: 54
STACK: [1, 0]
PC: 54
OPCODE: POP
REGISTERS: 0 150 0
NEXT PC: 55
STACK: [1]
PC: 55
OPCODE: MUL
REGISTERS: 0 150 0
NEXT PC: 56
STACK: [1]
PC: 56
OPCODE: SWAP_AB
REGISTERS: 150 0 0
NEXT PC: 57
STACK: [1]
PC: 57
OPCODE: POP
REGISTERS: 1 0 0
NEXT PC: 58
STACK: []
PC: 58
OPCODE: SWAP_AB
REGISTERS: 0 1 0
NEXT PC: 59
STACK: []
  • the code here ends up doing 1 thing and you can kinda see the whole point of the code it, basically builds the 150 num and then uses the result of some kinda mathematical operation to figure out if we multiply and from there it uses that value to pick the next programming counter location/jump. This part is a little bit of guessing and thinking, if you have had a verification what are the two most likely values?
  • well its pass or fail, or in computer 0 or 1.
    • now that means the PC result is either 0 or 150, if we want to pass the flag and win we just do this:
case "CALL":
if self.pc == 59:
old_pc, old_a, old_b, old_c = self.pc, self.a, self.b, self.c
self.pc, self.a, self.b, self.c = old_a, old_b, old_c, old_pc
next_pc = 150

We can also solve it like this:

Terminal window
break *0x401b37
commands
silent
set $cur_ip = *(unsigned long long*)(0x4acb40)
if ($cur_ip == 0x0)
set *(unsigned long long*)(0x4acb48) = 0xffffffffffffffff
set $rip = 0x401b3f
end
continue
end
run

Why does this work? Well the VM actually ends up actually checking division by a large number where it checks if the resulting value is 1 at the end the division is actually pretty obvious and very much at the start we just didn’t make sense of it till now:

PC: 0
OPCODE: INC
REGISTERS: 1 0 0
NEXT PC: 1
STACK: []
PC: 1
OPCODE: PUSH
REGISTERS: 1 0 0
NEXT PC: 2
STACK: [1]
PC: 2
OPCODE: SWAP_AB
REGISTERS: 0 1 0
NEXT PC: 3
STACK: [1]
PC: 3
OPCODE: ZERO
REGISTERS: 0 1 0
NEXT PC: 4
STACK: [1]
PC: 4
OPCODE: DEC
REGISTERS: 18446744073709551615 1 0
NEXT PC: 5
STACK: [1]
PC: 5
OPCODE: SWAP_AB
REGISTERS: 1 18446744073709551615 0
NEXT PC: 6
STACK: [1]
PC: 6
OPCODE: DIVU
REGISTERS: 0 18446744073709551615 0
NEXT PC: 7
STACK: [1]

which means if we want the code to pass all we really need to do is set the starting value of r1 to 18446744073709551615.

whatever-floats-your-boat

The purest programming language you've ever seen, transpiling into google sheets!

Download: boat_vm & float_program.bin

  • fucking sudoku, its always fucking sudoku

On a serious note it actually is just a VM with sudoku that ends up getting solved. There is a math formula to figure out the final flag, the grid has a single solve and the solver is really dog shit which is why it takes so long.

main function:

00401a30 int64_t main(int32_t arg1, void* arg2)
00401a30 {
00401a30 if (arg1 <= 1)
00401a3b {
00401a70 print("Expected filename as first arg");
00401a7a exit(1);
00401a7a /* no return */
00401a3b }
00401a3b
00401a41 instruciton_parser(*(uint64_t*)((char*)arg2 + 8));
00401a48 int32_t mxcsr;
00401a48 int64_t mxcsr_1 = vm_init(mxcsr);
00401a59 char i;
00401a59
00401a59 do
00401a52 i = excute_vm(mxcsr_1);
00401a59 while (!i);
00401a5d clean_up();
00401a68 return 0;
00401a30 }

lets look at our byte loader (🧌 this time in ida cuz my teammate did all the clean up for it and I am not doing it again)

void __fastcall load_and_parse_bytecode(const char *filename)
{
FILE *file_handle; // rax
FILE *file_handle_also; // r12
__int64 file_size_bytes; // r13
unsigned __int64 file_size_bytes_1; // rbp
unsigned __int64 file_size_aligned; // r13
unsigned __int64 num_qwords_in_file; // rbp
uint64_t *raw_bytecode_buffer; // rax
uint64_t *raw_bytecode_buffer_1; // rbx
__int64 raw_bytecode_idx; // rcx
unsigned __int64 *write_pointer; // rdi
unsigned __int64 raw_bytecode_idx_1; // rsi
__int64 parsed_instr_count_1; // r8
unsigned __int64 opcode; // rax
unsigned __int64 parsed_instr_count; // rdx
uint64_t v15; // xmm0_8
__int64 current_qword; // r13
__int64 mantissa; // rax
__int64 exponent; // rdx
file_handle = IO_new_fopen(filename, "rb");
if ( !file_handle )
{
IO_puts("Failed to open file");
exit(1);
}
file_handle_also = file_handle;
_fseeko(file_handle, 0, 2);
file_size_bytes = IO_ftell(file_handle_also);
rewind(file_handle_also);
file_size_bytes_1 = file_size_bytes;
file_size_aligned = file_size_bytes & 0xFFFFFFFFFFFFFFF8LL;
num_qwords_in_file = file_size_bytes_1 >> 3;
raw_bytecode_buffer = _libc_calloc(num_qwords_in_file, 8);
raw_bytecode_buffer_1 = raw_bytecode_buffer;
if ( !raw_bytecode_buffer
|| (_fread_chk(raw_bytecode_buffer, file_size_aligned, 8, num_qwords_in_file, file_handle_also),
g_vm_instructions = _libc_calloc(num_qwords_in_file, 16),
(write_pointer = g_vm_instructions) == 0) )
{
IO_puts("Failed to allocate required data");
exit(1);
}
if ( num_qwords_in_file )
{
raw_bytecode_idx_1 = 0;
parsed_instr_count_1 = 0;
while ( 1 )
{
current_qword = raw_bytecode_buffer_1[raw_bytecode_idx_1];
mantissa = current_qword & 0xFFFFFFFFFFFFFLL;
exponent = (current_qword >> 52) & 0x7FF;
if ( exponent == 2047 )
{
ILLEGAL_OPCODE:
printf(2, "[%zu] Illegal opcode: %.17g\n", raw_bytecode_idx_1, raw_bytecode_idx, parsed_instr_count_1, -1);
exit(1);
}
if ( !exponent ) // exponent is zero
break;
if ( exponent - 1023 < 0 ) // exponent too small
goto ILLEGAL_OPCODE;
if ( exponent - 1023 > 51 )
{
opcode = (mantissa | 0x10000000000000LL) << ((current_qword >> 52) - 51);
if ( current_qword < 0 )
opcode = -opcode;
}
else
{
raw_bytecode_idx = ~(-1LL << (52 - ((current_qword >> 52) + 1)));
if ( (mantissa & raw_bytecode_idx) != 0 )
goto ILLEGAL_OPCODE;
if ( current_qword < 0 )
{
LABEL_28:
IO_puts("Unexpected opcode");
exit(1);
}
opcode = (mantissa | 0x10000000000000LL) >> (51 - (current_qword >> 52));
}
if ( opcode > 0x21 )
goto LABEL_28;
LODWORD(raw_bytecode_idx) = opcode - 19;
parsed_instr_count = raw_bytecode_idx_1 + 1;
if ( opcode - 19 <= 7 || !opcode ) // it has an operand
goto TWO_WORD_HANDLER;
++raw_bytecode_idx_1;
v15 = 0;
STORE_INSTRUCTION:
*write_pointer = opcode;
++parsed_instr_count_1;
write_pointer += 2;
*(write_pointer - 1) = v15;
if ( raw_bytecode_idx_1 >= num_qwords_in_file )
goto EXIT_LOOP;
}
if ( mantissa )
goto ILLEGAL_OPCODE;
parsed_instr_count = raw_bytecode_idx_1 + 1;
opcode = 0;
TWO_WORD_HANDLER:
if ( parsed_instr_count >= num_qwords_in_file )
{
IO_puts("Unexpected end of data");
exit(1);
}
v15 = raw_bytecode_buffer_1[parsed_instr_count];
raw_bytecode_idx_1 += 2LL;
goto STORE_INSTRUCTION;
}
parsed_instr_count_1 = 0;
EXIT_LOOP:
g_vm_instr_count = parsed_instr_count_1;
free(raw_bytecode_buffer_1);
}

so after cleaning it up like any sane team we threw it into AI doing so will immediately tell you its using the standard IEEE-754 double-precision which is in english just means:

  • every 64 bit qword:

image.png

After this it ends up using the exponent to determine what opcode the data is, as well defining values for incorrect opcodes.

  • when all is said and done
  • valid opcodes are 0-33
    • opcodes 0, 19-26 require an operand (the next qword)

in python without error code:

def load_program(self, program):
n = len(program) // 8
buf = [struct.unpack("<Q", program[i*8:(i+1)*8])[0] for i in range(n)]
i = 0
self.program = []
while i < n:
b = buf[i]
m = b & 0xFFFFFFFFFFFFF
e = (b >> 52) & 0x7FF
if e == 0:
op = 0
else:
E = e - 1023
if E >= 52:
op = ((m | (1 << 52)) << (E - 52))
if (b >> 63) & 1:
op = -op
else:
op = (m | (1 << 52)) >> (52 - E)
need_operand = (op == 0) or (19 <= op <= 26)
if need_operand:
self.program.append((op, buf[i + 1] if i + 1 < n else 0))
i += 2
else:
self.program.append((op, 0))
i += 1
self.instr_count = len(self.program)

The next part of the VM we care about is actual interpreter. I’ll be honest, this was a lot of just going through and cleaning up the ASM, adding code comments as needed to describe the behavior (by my teammate cold) 😅. The entire thing was pretty ugly the most annoying parts are probably the jump tables as well as how the MXCSR ends up being used as a flag system of sorts:

loc_402220: ; CODE XREF: .text:0000000000401FBF↑j
; DATA XREF: .rodata:jpt_401FBF↓o
stmxcsr dword ptr [rsp+0Ch] ; jumptable 0000000000401FBF cases 19-25
mov eax, [rsp+0Ch]
sub rdx, 14h ; switch 6 cases
mov edi, eax
and edi, 3Fh
cmp rdx, 5
ja def_40224A ; jumptable 000000000040224A default case
lea rcx, jpt_40224A
movsxd rdx, ds:(jpt_40224A - 4A8078h)[rcx+rdx*4]
add rdx, rcx
db 3Eh ; switch jump
jmp rdx
  • this will save the status
  • load it into eax and edi
    • and with 0x3f to get the exception flags
  • from there it uses the opcodes to breakd own into a 6 entry jump table
    • 0x4028F6 → shr eax,5 → tests UE
    • 0x4028DD → shr eax,3 → tests OE
    • 0x4028D2 → shr eax,4 → tests ZE
    • 0x4027F0 → test edi,edi → tests “any”
    • 0x4028E8 → and edi,1 → tests IE

here is my final VM + disasm, if we are being honest I would say its like 60% me and then 40% AI fixing my mistakes lmafo

import struct
import math
import sys
def bits_to_float(u): return struct.unpack('<d', struct.pack('<Q', u))[0]
def float_to_bits(f): return struct.unpack('<Q', struct.pack('<d', f))[0]
def dblbits_to_int(u):
m = u & ((1<<52)-1)
e = (u >> 52) & 0x7FF
if e == 0: return 0
s = (u >> 63) & 1
k = int(e) - 1075
v = ((1<<52) | m) << k if k >= 0 else ((1<<52) | m) >> (-k)
return -v if s else v
def set_flags_bin(a,b,res,op):
f = 0
if math.isnan(a) or math.isnan(b) or math.isnan(res): f |= 1<<0
den = lambda x: (x!=0.0 and abs(x) < sys.float_info.min)
if den(a) or den(b): f |= 1<<1
if op == 'div' and b == 0.0 and not math.isnan(a): f |= 1<<2
if math.isinf(res): f |= 1<<3
if res != 0.0 and abs(res) < sys.float_info.min: f |= 1<<4
pe = False
if all(map(math.isfinite, [a,b,res])):
if op == 'add':
pe = not ((res - b == a) and (res - a == b))
elif op == 'sub':
pe = not ((res + b == a) and (a - res == b))
elif op == 'mul':
if a != 0.0 and b != 0.0:
pe = not ((res / b == a) and (res / a == b))
else:
pe = False
elif op == 'div':
if b != 0.0:
pe = not (res * b == a)
else:
pe = False
if pe: f |= 1<<5
return f
def set_flags_un(a,res):
f = 0
if math.isnan(a) or math.isnan(res): f |= 1<<0
den = lambda x: (x!=0.0 and abs(x) < sys.float_info.min)
if den(a): f |= 1<<1
if math.isinf(res): f |= 1<<3
if res != 0.0 and abs(res) < sys.float_info.min: f |= 1<<4
try:
if math.isfinite(a) and math.isfinite(res):
if float(int(res)) == res and (not float(a).is_integer()):
f |= 1<<5
except:
pass
return f
class VM:
def __init__(self, program):
self.ip = 0
self.stack = []
self.callstack = []
self.memory = {}
self.program = []
self.load_program(program)
def load_program(self, program):
n = len(program) // 8
buf = [struct.unpack("<Q", program[i*8:(i+1)*8])[0] for i in range(n)]
i = 0
self.program = []
while i < n:
b = buf[i]
m = b & 0xFFFFFFFFFFFFF
e = (b >> 52) & 0x7FF
if e == 0x7FF:
raise ValueError("Illegal opcode (NaN/Inf)")
if e == 0:
if m != 0:
raise ValueError("Illegal opcode (subnormal)")
op = 0
else:
E = e - 1023
if E < 0:
raise ValueError("Illegal opcode (negative exponent)")
if E >= 52:
op = ((m | (1<<52)) << (E - 52))
if (b >> 63) & 1:
op = -op
else:
need_zero = (1 << (52 - E)) - 1
if (m & need_zero) != 0:
raise ValueError("Illegal opcode (frac bits)")
if (b >> 63) & 1:
raise ValueError("Unexpected opcode")
op = (m | (1<<52)) >> (52 - E)
if op > 0x21:
raise ValueError("Unexpected opcode")
need_operand = (op == 0) or (19 <= op <= 26)
if need_operand:
if i + 1 >= n:
raise ValueError("Unexpected end of data")
self.program.append((op, buf[i+1]))
i += 2
else:
self.program.append((op, 0))
i += 1
self.instr_count = len(self.program)
def DISAM(self):
names = {
0:"PUSH",1:"POP",2:"DUP",3:"DUP2",4:"OVER",5:"SWAP",6:"STORE",
7:"ADD",8:"SUB",9:"MUL",10:"DIV",11:"TRUNC",12:"CEIL",13:"FLOOR",14:"ROUND",15:"ABS",
16:"MIN",17:"MAX",18:"CLRFLAGS",19:"BR_ZE",20:"BR_PE",21:"BR_IE",22:"BR_OE",23:"BR_UE",24:"BR_ANY",25:"JMP",
26:"CALL",27:"RET",28:"PRINT",29:"PUTC",30:"SCANF",31:"GETC",32:"LOAD",33:"NOP"
}
for idx,(op,arg) in enumerate(self.program):
m = names.get(op,f"OP{op}")
if op == 0:
v = bits_to_float(arg)
print(f"{idx:04d}: {m} {v:.17g}")
elif 19 <= op <= 25 or op == 26:
iv = dblbits_to_int(arg)
fv = bits_to_float(arg)
print(f"{idx:04d}: {m} {iv} ({fv:.17g})")
else:
print(f"{idx:04d}: {m}")
def run(self):
self.globals = {}
self.flags = 0
self.ip = 0
n = len(self.program)
while 0 <= self.ip < n:
op, arg = self.program[self.ip]
if op == 0:
self.stack.append(bits_to_float(arg)); self.ip += 1; continue
if op == 1:
if not self.stack: raise RuntimeError("stack underflow")
self.stack.pop(); self.ip += 1; continue
if op == 2:
if not self.stack: raise RuntimeError("stack underflow")
self.stack.append(self.stack[-1]); self.ip += 1; continue
if op == 3:
if len(self.stack) < 2: raise RuntimeError("stack underflow")
a,b = self.stack[-2], self.stack[-1]
self.stack.extend([a,b]); self.ip += 1; continue
if op == 4:
if len(self.stack) < 2: raise RuntimeError("stack underflow")
a,b = self.stack[-2], self.stack[-1]
self.stack[-2], self.stack[-1] = b, a
self.stack.append(b); self.ip += 1; continue
if op == 5:
if len(self.stack) < 2: raise RuntimeError("stack underflow")
self.stack[-1], self.stack[-2] = self.stack[-2], self.stack[-1]; self.ip += 1; continue
if op == 6:
if len(self.stack) < 2: raise RuntimeError("stack underflow")
addr_bits = float_to_bits(self.stack[-1]); val = self.stack[-2]
addr = dblbits_to_int(addr_bits)
self.stack.pop(); self.stack.pop()
self.globals[addr] = val
self.ip += 1; continue
if op in (7,8,9,10,16,17):
if len(self.stack) < 2: raise RuntimeError("stack underflow")
b = self.stack.pop(); a = self.stack.pop()
if op == 7: r = a + b; self.flags = set_flags_bin(a,b,r,'add')
elif op == 8: r = a - b; self.flags = set_flags_bin(a,b,r,'sub')
elif op == 9: r = a * b; self.flags = set_flags_bin(a,b,r,'mul')
elif op == 10:
r = a / b if b != 0.0 else (math.copysign(math.inf,a) if not math.isnan(a) else math.nan)
self.flags = set_flags_bin(a,b,r,'div')
elif op == 16: r = a if (not math.isnan(a) and (math.isnan(b) or a < b)) else b; self.flags &= 0
else: r = a if (not math.isnan(a) and (math.isnan(b) or a > b)) else b; self.flags &= 0
self.stack.append(r); self.ip += 1; continue
if op in (11,12,13,14,15):
if not self.stack: raise RuntimeError("stack underflow")
x = self.stack.pop()
if op == 11: r = float(math.trunc(x))
elif op == 12: r = float(math.ceil(x))
elif op == 13: r = float(math.floor(x))
elif op == 14: r = float(round(x))
else: r = abs(x)
self.flags = set_flags_un(x,r)
self.stack.append(r); self.ip += 1; continue
if op == 18:
self.flags = 0; self.ip += 1; continue
if op in (19,20,21,22,23,24,25):
off = dblbits_to_int(arg)
take = False
if op == 19: take = bool(self.flags & (1<<2))
elif op == 20: take = bool(self.flags & (1<<5))
elif op == 21: take = bool(self.flags & (1<<0))
elif op == 22: take = bool(self.flags & (1<<3))
elif op == 23: take = bool(self.flags & (1<<4))
elif op == 24: take = bool(self.flags & 0x3F)
else: take = True
if take:
ni = self.ip + off
if not (0 <= ni < n): raise RuntimeError("branch out of range")
self.ip = ni
else:
self.ip += 1
continue
if op == 26:
self.callstack.append(self.ip + 1)
self.ip = dblbits_to_int(arg)
if not (0 <= self.ip < n): raise RuntimeError("invalid call target")
continue
if op == 27:
if not self.callstack: return
self.ip = self.callstack.pop()
continue
if op == 28:
if not self.stack: raise RuntimeError("stack underflow")
v = self.stack.pop()
sys.stdout.write(f"{v:.17g}")
sys.stdout.flush()
self.ip += 1
continue
if op == 29:
if not self.stack: raise RuntimeError("stack underflow")
ch = int(self.stack.pop()) & 0xFF
sys.stdout.write(chr(ch)); sys.stdout.flush()
self.ip += 1
continue
if op == 30:
line = sys.stdin.readline()
if not line: raise RuntimeError("failed to read float")
self.stack.append(float(line.strip()))
self.ip += 1
continue
if op == 31:
c = sys.stdin.read(1)
if c == "": raise RuntimeError("getc EOF")
self.stack.append(float(ord(c)))
self.ip += 1
continue
if op == 32:
if not self.stack: raise RuntimeError("stack underflow")
addr = dblbits_to_int(float_to_bits(self.stack[-1]))
self.stack[-1] = self.globals.get(addr, 0.0)
self.ip += 1
continue
if op == 33:
self.ip += 1
continue
raise RuntimeError(f"unknown opcode {op}")
def main():
f = open("float_program.bin", "rb")
program = f.read()
f.close()
vm = VM(program)
if any(a.lower() in ("--disam","--disasm","-d") for a in sys.argv[1:]):
vm.DISAM()
else:
vm.run()
if __name__ == "__main__":
main()

Running our interpreter gets us the same result as the binary which is a good sign:

(control_env) tarun@mac boat_vm % python3 emu.py
....
..';::c:;,'.
..,;:cccccccccc:;,'.
.',;::ccccorctf{...}c:c::;,'.
.....';:ccccccccccc:ccc::;,'...
.;kOo. .',.....',;:::::cccc:;,'.......
'oOdclxkd..,,,,'.....',;::;,............
'lkdl:;;:oxd...',,'..,,'................... ,.
.ckdl::;;cll:;;.....;kdc'',,;'............. .. xkxo;.
';. .:kOxoll:;;;;;,,;,..''':oc,.oxkd' ........ ...... cxO0xl'
.:c;,::. ;xOkOOkdl:;;;;;;;,,,..,,,;c;'.dOOO' ............':lxOOxoc;.
.:oc,,,,,:dO0Oko:;;;;;;;;,,,,,,,.',,;;;,..:000c ........';cxOOxoc:;;;;.
,ol:;;;;lxO0kdl:;;,,;,,,,,,,,,,,:l..,,,,,,,,,xkOx.....':ok0Oxlc:;;;;;;;,.
.,::;;,cdkOo:;;;;,,,,,,;;;,,:ldkkx,...',,,,,,oOOk'.;ok0Oxoc;;,;;;;;,,,,,.
.:;;:dxkkkkxl;,,,,,,;:ldkkOkkkkkko:'...',,;0OxxOOxoc:;;;;;,,,,,,;;,,
.;:oolldxkO0OxlcldkkOOkxxxkkxdddxkkoc,;okO0kkl;;;;;;;;,,,,;;;,,.
.lllclooodxkO000OkxxxkkxdddxkkkkO0Okkxoo0OOo;;;,,,,,;;;,,,,;'.
.llcclloooooodxkO00OxdddxkOOO00OOxoc;;;;OOOk,,,,;;;,,,,,,..
.llollcccloooloooddxkO0kxOOOOxoc;;;,,;;,d0Ok:,,,,,,''..
.;llllollccllooocoodc;codc;;;;;;;,,,,,:00Od,,,,..
.cllcclllllccclcllo;;:lo;;;;,,,,,;;,,,x0xd,.
.,::clllllolcccc,,;cl;,,,;;;,,,,;;,:kxx:
.,cllllllolc;;:ll;;;;,,,','.. OOOO
.,cc:ccc;;;:c;,,,,'. c0O0,
.'clll:,,;,.. .0O0x
.,;.. ,,x0.
.;.
..
____ ___ _ _____ __ ____ __
| __ ) / _ \ / \|_ _| \ \ / / \/ |
| _ \| | | |/ _ \ | | \ \ / /| |\/| |
| |_) | |_| / ___ \| | \ V / | | | |
|____/ \___/_/ \_\_| _____ \_/ |_| |_|
|_____|
Running corCTF flag cracker.
Hang tight, I'm using hardware acceleration to fulfill out your request...
Thanks for waiting! Just a bit longer...
Estimated time remaining: 2.2185312001337001e+57 seconds

hmm seems our code is stalling 🤔 lets look at the disasm, most of it just ends up being print values:

  • We can see a rough idea of the FLAG:corctf{g3ntly_d0wn_th3_StR3AM_<float>FPU_h4cks}
    • all of this is just ascii prints at the bottom

so what exactly is this all the actually useful logic is:

0000: CALL 1000 (1000)
0001: RET
0002: PUSH 4.2006942069000003e-05
0003: MAX
0004: PUSH 1.1102230246251565e-16
0005: CLRFLAGS
0006: ADD
0007: POP
0008: PUSH 1
0009: PUSH 0
0010: BR_PE 2 (2)
0011: SWAP
0012: POP
0013: RET
0014: PUSH 4.2006942069000003e-05
0015: MAX
0016: PUSH 1.7976931348623157e+308
0017: SWAP
0018: DUP2
0019: PUSH 1
0020: SWAP
0021: DIV
0022: CLRFLAGS
0023: DIV
0024: POP
0025: DIV
0026: POP
0027: BR_OE 3 (3)
0028: PUSH 1
0029: RET
0030: PUSH 0
0031: RET
0032: PUSH -1
0033: PUSH -1
0034: PUSH -1
0035: PUSH -1
0036: PUSH -1
0037: PUSH -1
0038: PUSH -1
0039: PUSH 1
0040: PUSH -1
0041: PUSH 4
0042: PUSH -1
0043: PUSH -1
0044: PUSH -1
0045: PUSH -1
0046: PUSH -1
0047: PUSH -1
0048: PUSH -1
0049: PUSH -1
0050: PUSH -1
0051: PUSH 2
0052: PUSH -1
0053: PUSH -1
0054: PUSH -1
0055: PUSH -1
0056: PUSH -1
0057: PUSH -1
0058: PUSH -1
0059: PUSH -1
0060: PUSH -1
0061: PUSH -1
0062: PUSH -1
0063: PUSH 5
0064: PUSH -1
0065: PUSH 4
0066: PUSH -1
0067: PUSH 7
0068: PUSH -1
0069: PUSH -1
0070: PUSH 8
0071: PUSH -1
0072: PUSH -1
0073: PUSH -1
0074: PUSH 3
0075: PUSH -1
0076: PUSH -1
0077: PUSH -1
0078: PUSH -1
0079: PUSH 1
0080: PUSH -1
0081: PUSH 9
0082: PUSH -1
0083: PUSH -1
0084: PUSH -1
0085: PUSH -1
0086: PUSH 3
0087: PUSH -1
0088: PUSH -1
0089: PUSH 4
0090: PUSH -1
0091: PUSH -1
0092: PUSH 2
0093: PUSH -1
0094: PUSH -1
0095: PUSH -1
0096: PUSH 5
0097: PUSH -1
0098: PUSH 1
0099: PUSH -1
0100: PUSH -1
0101: PUSH -1
0102: PUSH -1
0103: PUSH -1
0104: PUSH -1
0105: PUSH -1
0106: PUSH -1
0107: PUSH 8
0108: PUSH -1
0109: PUSH 6
0110: PUSH -1
0111: PUSH -1
0112: PUSH -1
0113: PUSH 81
0114: DUP
0115: PUSH 1
0116: SWAP
0117: CLRFLAGS
0118: DIV
0119: POP
0120: BR_ZE 6 (6)
0121: PUSH -1
0122: ADD
0123: OVER
0124: NOP
0125: JMP -11 (-11)
0126: POP
0127: PUSH 2
0128: PUSH 101
0129: NOP
0130: PUSH 3
0131: PUSH 102
0132: NOP
0133: PUSH 5
0134: PUSH 103
0135: NOP
0136: PUSH 7
0137: PUSH 104
0138: NOP
0139: PUSH 11
0140: PUSH 105
0141: NOP
0142: PUSH 13
0143: PUSH 106
0144: NOP
0145: PUSH 17
0146: PUSH 107
0147: NOP
0148: PUSH 19
0149: PUSH 108
0150: NOP
0151: PUSH 23
0152: PUSH 109
0153: NOP
0154: PUSH -4.2068999999999998e-12
0155: PUSH 99
0156: NOP
0157: RET
0158: PUSH 100
0159: ADD
0160: LOAD
0161: RET
0162: PUSH 1
0163: PUSH 200
0164: NOP
0165: PUSH 9
0166: OVER
0167: MUL
0168: SWAP
0169: DUP
0170: PUSH 1
0171: SWAP
0172: CLRFLAGS
0173: DIV
0174: POP
0175: BR_ZE 14 (14)
0176: PUSH -1
0177: ADD
0178: DUP2
0179: ADD
0180: LOAD
0181: CALL 158 (158)
0182: PUSH 200
0183: OVER
0184: LOAD
0185: MUL
0186: SWAP
0187: NOP
0188: JMP -19 (-19)
0189: POP
0190: POP
0191: PUSH 200
0192: LOAD
0193: PUSH 223092870
0194: DIV
0195: CALL 14 (14)
0196: RET
0197: PUSH 1
0198: PUSH 200
0199: NOP
0200: PUSH 81
0201: ADD
0202: DUP
0203: PUSH 9
0204: DIV
0205: CALL 2 (2)
0206: PUSH 1
0207: SWAP
0208: CLRFLAGS
0209: DIV
0210: POP
0211: BR_ZE 13 (13)
0212: PUSH -9
0213: ADD
0214: DUP
0215: LOAD
0216: CALL 158 (158)
0217: PUSH 200
0218: OVER
0219: LOAD
0220: MUL
0221: SWAP
0222: NOP
0223: JMP -21 (-21)
0224: POP
0225: PUSH 200
0226: LOAD
0227: PUSH 223092870
0228: DIV
0229: CALL 14 (14)
0230: RET
0231: PUSH 1
0232: PUSH 200
0233: NOP
0234: DUP
0235: PUSH 3
0236: DIV
0237: TRUNC
0238: OVER
0239: PUSH 3
0240: MUL
0241: SUB
0242: PUSH 3
0243: MUL
0244: SWAP
0245: PUSH 27
0246: MUL
0247: ADD
0248: DUP
0249: PUSH 0
0250: ADD
0251: LOAD
0252: CALL 158 (158)
0253: PUSH 200
0254: OVER
0255: LOAD
0256: MUL
0257: SWAP
0258: NOP
0259: DUP
0260: PUSH 1
0261: ADD
0262: LOAD
0263: CALL 158 (158)
0264: PUSH 200
0265: OVER
0266: LOAD
0267: MUL
0268: SWAP
0269: NOP
0270: DUP
0271: PUSH 2
0272: ADD
0273: LOAD
0274: CALL 158 (158)
0275: PUSH 200
0276: OVER
0277: LOAD
0278: MUL
0279: SWAP
0280: NOP
0281: DUP
0282: PUSH 9
0283: ADD
0284: LOAD
0285: CALL 158 (158)
0286: PUSH 200
0287: OVER
0288: LOAD
0289: MUL
0290: SWAP
0291: NOP
0292: DUP
0293: PUSH 10
0294: ADD
0295: LOAD
0296: CALL 158 (158)
0297: PUSH 200
0298: OVER
0299: LOAD
0300: MUL
0301: SWAP
0302: NOP
0303: DUP
0304: PUSH 11
0305: ADD
0306: LOAD
0307: CALL 158 (158)
0308: PUSH 200
0309: OVER
0310: LOAD
0311: MUL
0312: SWAP
0313: NOP
0314: DUP
0315: PUSH 18
0316: ADD
0317: LOAD
0318: CALL 158 (158)
0319: PUSH 200
0320: OVER
0321: LOAD
0322: MUL
0323: SWAP
0324: NOP
0325: DUP
0326: PUSH 19
0327: ADD
0328: LOAD
0329: CALL 158 (158)
0330: PUSH 200
0331: OVER
0332: LOAD
0333: MUL
0334: SWAP
0335: NOP
0336: DUP
0337: PUSH 20
0338: ADD
0339: LOAD
0340: CALL 158 (158)
0341: PUSH 200
0342: OVER
0343: LOAD
0344: MUL
0345: SWAP
0346: NOP
0347: POP
0348: PUSH 200
0349: LOAD
0350: PUSH 223092870
0351: DIV
0352: CALL 14 (14)
0353: RET
0354: PUSH 9
0355: DUP
0356: CALL 2 (2)
0357: PUSH 1
0358: SWAP
0359: CLRFLAGS
0360: DIV
0361: POP
0362: BR_ZE 13 (13)
0363: PUSH 1
0364: SUB
0365: DUP
0366: CALL 162 (162)
0367: SWAP
0368: DUP
0369: CALL 197 (197)
0370: SWAP
0371: DUP
0372: CALL 231 (231)
0373: SWAP
0374: JMP -19 (-19)
0375: POP
0376: PUSH 1
0377: MUL
0378: MUL
0379: MUL
0380: MUL
0381: MUL
0382: MUL
0383: MUL
0384: MUL
0385: MUL
0386: MUL
0387: MUL
0388: MUL
0389: MUL
0390: MUL
0391: MUL
0392: MUL
0393: MUL
0394: MUL
0395: MUL
0396: MUL
0397: MUL
0398: MUL
0399: MUL
0400: MUL
0401: MUL
0402: MUL
0403: MUL
0404: CALL 14 (14)
0405: RET
0406: DUP
0407: PUSH 81
0408: DIV
0409: CALL 14 (14)
0410: PUSH 1
0411: SWAP
0412: CLRFLAGS
0413: DIV
0414: POP
0415: BR_ZE 4 (4)
0416: POP
0417: CALL 354 (354)
0418: RET
0419: DUP
0420: LOAD
0421: CALL 2 (2)
0422: PUSH 1
0423: SWAP
0424: CLRFLAGS
0425: DIV
0426: POP
0427: BR_ZE 5 (5)
0428: PUSH 1
0429: ADD
0430: CALL 406 (406)
0431: RET
0432: PUSH 9
0433: DUP
0434: CALL 2 (2)
0435: PUSH 1
0436: SWAP
0437: CLRFLAGS
0438: DIV
0439: POP
0440: BR_ZE 22 (22)
0441: DUP2
0442: SWAP
0443: NOP
0444: SWAP
0445: OVER
0446: PUSH 1
0447: ADD
0448: CALL 406 (406)
0449: PUSH 1
0450: SWAP
0451: CLRFLAGS
0452: DIV
0453: POP
0454: BR_ZE 5 (5)
0455: POP
0456: POP
0457: PUSH 1
0458: RET
0459: PUSH -1
0460: ADD
0461: JMP -28 (-28)
0462: POP
0463: PUSH -1
0464: SWAP
0465: NOP
0466: PUSH 0
0467: RET
0468: PUSH 0
0469: CALL 406 (406)
0470: RET
0471: PUSH 6.9e-10
0472: PUSH 200
0473: NOP
0474: PUSH 0
0475: DUP
0476: PUSH 81
0477: DIV
0478: CALL 2 (2)
0479: PUSH 1
0480: SWAP
0481: SUB
0482: PUSH 1
0483: SWAP
0484: CLRFLAGS
0485: DIV
0486: POP
0487: BR_ZE 17 (17)
0488: DUP
0489: LOAD
0490: PUSH 200
0491: OVER
0492: LOAD
0493: PUSH 3.1415926535897931
0494: MUL
0495: SWAP
0496: MUL
0497: PUSH 2.7182818284590451
0498: ADD
0499: SWAP
0500: NOP
0501: PUSH 1
0502: ADD
0503: JMP -28 (-28)
0504: PUSH 200
0505: LOAD
0506: RET
  • 0032-0112 is the actual sudoku board
    • -1 = blank, with the given values
  • the rest is some kinda validation system
  • then there is a the slow backtracking solver which is why its so bad

from there we get the whole mathematical logic to build the float from the actual solver

which results in the actual final flag:

its rather simple just:

a = 6.9e-10
pi = 3.1415926535897931
e = 2.7182818284590451
for i in range(9):
for j in range(9):
c = grid[i][j]
a = (a * pi * c) + e
f = a

here is the actual matrix + the solve (there is only one):

image.png

final flag: corctf{g3ntly_d0wn_th3_StR3AM_3.0390693652230334e+89_FPU_h4cks}

solve

import math
grid = [
[6, 9, 3, 7, 8, 4, 5, 1, 2],
[4, 8, 7, 5, 1, 2, 9, 3, 6],
[1, 2, 5, 9, 6, 3, 8, 7, 4],
[9, 3, 2, 6, 5, 1, 4, 8, 7],
[5, 6, 8, 2, 4, 7, 3, 9, 1],
[7, 4, 1, 3, 9, 8, 6, 2, 5],
[3, 1, 9, 4, 7, 5, 2, 6, 8],
[8, 5, 6, 1, 2, 9, 7, 4, 3],
[2, 7, 4, 8, 3, 6, 1, 5, 9]
]
a = 6.9e-10
pi = 3.1415926535897931
e = 2.7182818284590451
for i in range(9):
for j in range(9):
c = grid[i][j]
a = (a * pi * c) + e
f = a
float_str = f"{f:.17g}"
flag = f"corctf{{g3ntly_d0wn_th3_StR3AM_{float_str}_FPU_h4cks}}"
print(flag)