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) != 0from there we have an extra check to see if the character is odd and at an index greater then 3, the characterpis not allowed and if its less then or equal to 3 it must be a character greater thankand nots. - Second the even indices can’t be
cor greater thenl
- First we break up the code by odd or evens using
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?
- write == read during the enqueue
- write ≠ read during the dequeue
- we must be valid in the char index checks
- odd index > 3
pbanned
- odd index ≤ 3
- char >
kand nots
- char >
- even index
- char >
land notc
- char >
- odd index > 3
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
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 theandfunction 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
_rawValagain 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
- no real number other then 0 will ever pass it
- 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_rawValis called on this object we return true. - next we need to see how exactly the check works:
- we are able to define it as always being true, how exactly does this work? well since we are able to redefine the
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
avalue and then do anandchain 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
Truthclass by causing theandvalue to always equal back toTruth()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
greaterThancheck
- we need to pass
- nonzero
- we need to pass
notEqualscheck
- we need to pass
- pow
- we need the actual numeric value of the object to equal 0 when called
- ie
_rawVal
- ie
- we need the actual numeric value of the object to equal 0 when called
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.txt00000000: 6c6e 636b 6d63 696e 6b6e 6c63 6f66 6e6b lnckmcinknlcofnk00000010: 6c6c 6863 6f66 6e6b 6c6c 6863 6f66 6e6b llhcofnkllhcofnk00000020: 6c6c 6863 6f66 6e6b 6c6c 6863 6f63 656d llhcofnkllhcocem00000030: 6d6d 6d6d 6863 6f68 636f 6361 7372 6c73 mmmmhcohcocasrls00000040: 7366 6b6d 6c6a 6d67 6c64 6e6d 6371 6c6d sfkmljmgldnmcqlm00000050: 6d6a 616c 646f 6866 6b6f 6967 7263 7173 mjaldohfkoigrcqs00000060: 6263 6e62 6167 716c 7162 6764 6a67 6c6c bcnbagqlqbgdjgll00000070: 6c6c 6c6c 6c6c 716d 6d6d 6d6d 6d63 6561 llllllqmmmmmmcea00000080: 626f 736b 616b 6e73 6872 6272 6a67 6671 boskaknshrbrjgfq00000090: 7171 6662 7073 6b6e 6c63 6b6c 6c68 636b qqfbpsknlckllhck000000a0: 6c6c 6863 6b6c 6c68 636f 666e 6b6c 6c68 llhckllhcofnkllh000000b0: 636f 666e 6b6c 6c68 636b 6c6c 6863 6f66 cofnkllhckllhcof000000c0: 6e6b 6c6c 6863 6f66 6e6b 6c6c 6863 6f66 nkllhcofnkllhcof000000d0: 6e6b 6c6c 6863 6f66 6e6b 6c6c 6863 6f66 nkllhcofnkllhcofThis 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.
- we can see how it does a simple set up for the numeric case swaps by subtracting
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:
set logging file execution_log.txtset logging enabled onecho Logging execution to execution_log.txt\n
# Function to map RAX values to case labelsdefine 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" endend
# Set breakpoint at jmp rax (0x401a2a)break *0x401a2acommands 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 continueendrunThe script is pretty simple I just break point on the jmp rax and see the value there.

I ended up seeing the same loop that I saw in my VM as well:
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 python3import sys
U64 = 0xFFFFFFFFFFFFFFFFSTACK_CAP = 0x800DEBUG = 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, 0x1800401c9c 488d6ef8 lea rbp, [rsi-0x8]00401ca0 4889ef mov rdi, rbp {r1}00401ca3 e898f3ffff call sub_40104000401ca8 48832d90ae0a0001 sub qword [rel r1], 0x100401cb0 48891da1ae0a00 mov qword [rel r2+8], rbx00401cb7 e9acfdffff jmp 0x401a68lets trace the following call: 00401ca3 e898f3ffff call sub_401040
00401040 int64_t sub_401040()
00401040 f30f1efa endbr6400401044 ff25c69f0a00 jmp qword [rel data_4ab010]lets follow the jump dynamically, doing that gets us to:
00417d80 f30f1efa endbr6400417d84 4889f8 mov rax, rdi00417d87 4883fa20 cmp rdx, 0x2000417d8b 7233 jb 0x417dc0
00417d8d 62e1fe286f06 vmovdqu64 ymm16, k0, ymmword [rsi]00417d93 4883fa40 cmp rdx, 0x4000417d97 0f87a3000000 ja 0x417e40
00417d9d 62e1fe286f4c16ff vmovdqu64 ymm17, k0, ymmword [rsi+rdx-0x20]00417da5 62e1fe287f07 vmovdqu64 ymmword [rdi], k0, ymm1600417dab 62e1fe287f4c17ff vmovdqu64 ymmword [rdi+rdx-0x20], k0, ymm1700417db3 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 byteslea rbp, [rsi-0x8] ; rbp = &r1.lowmov rdi, rbp ; rdi = &r1.low (destination)call sub_401040 ; memcpy(r1, r1+8, 24)sub qword [rel r1], 0x1 ; r1.low -= 1mov qword [rel r2+8], rbx ; r2.high = rbxin 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.pyPC: 0OPCODE: INCREGISTERS: 1 0 0NEXT PC: 1STACK: []
PC: 1OPCODE: PUSHREGISTERS: 1 0 0NEXT PC: 2STACK: [1]
PC: 2OPCODE: SWAP_ABREGISTERS: 0 1 0NEXT PC: 3STACK: [1]
... big jum p in PC
PC: 52OPCODE: MULREGISTERS: 150 15 0NEXT PC: 53STACK: [1, 0]
PC: 53OPCODE: SWAP_ABREGISTERS: 15 150 0NEXT PC: 54STACK: [1, 0]
PC: 54OPCODE: POPREGISTERS: 0 150 0NEXT PC: 55STACK: [1]
PC: 55OPCODE: MULREGISTERS: 0 150 0NEXT PC: 56STACK: [1]
PC: 56OPCODE: SWAP_ABREGISTERS: 150 0 0NEXT PC: 57STACK: [1]
PC: 57OPCODE: POPREGISTERS: 1 0 0NEXT PC: 58STACK: []
PC: 58OPCODE: SWAP_ABREGISTERS: 0 1 0NEXT PC: 59STACK: []- 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 = 150We can also solve it like this:
break *0x401b37commands silent set $cur_ip = *(unsigned long long*)(0x4acb40) if ($cur_ip == 0x0) set *(unsigned long long*)(0x4acb48) = 0xffffffffffffffff set $rip = 0x401b3f end continueendrunWhy 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: 0OPCODE: INCREGISTERS: 1 0 0NEXT PC: 1STACK: []
PC: 1OPCODE: PUSHREGISTERS: 1 0 0NEXT PC: 2STACK: [1]
PC: 2OPCODE: SWAP_ABREGISTERS: 0 1 0NEXT PC: 3STACK: [1]
PC: 3OPCODE: ZEROREGISTERS: 0 1 0NEXT PC: 4STACK: [1]
PC: 4OPCODE: DECREGISTERS: 18446744073709551615 1 0NEXT PC: 5STACK: [1]
PC: 5OPCODE: SWAP_ABREGISTERS: 1 18446744073709551615 0NEXT PC: 6STACK: [1]
PC: 6OPCODE: DIVUREGISTERS: 0 18446744073709551615 0NEXT PC: 7STACK: [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 }00401a3b00401a41 instruciton_parser(*(uint64_t*)((char*)arg2 + 8));00401a48 int32_t mxcsr;00401a48 int64_t mxcsr_1 = vm_init(mxcsr);00401a59 char i;00401a5900401a59 do00401a52 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:

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 UE0x4028DD→ shr eax,3 → tests OE0x4028D2→ shr eax,4 → tests ZE0x4027F0→ 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 structimport mathimport 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 vdef 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 fdef 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 secondshmm 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: RET0002: PUSH 4.2006942069000003e-050003: MAX0004: PUSH 1.1102230246251565e-160005: CLRFLAGS0006: ADD0007: POP0008: PUSH 10009: PUSH 00010: BR_PE 2 (2)0011: SWAP0012: POP0013: RET0014: PUSH 4.2006942069000003e-050015: MAX0016: PUSH 1.7976931348623157e+3080017: SWAP0018: DUP20019: PUSH 10020: SWAP0021: DIV0022: CLRFLAGS0023: DIV0024: POP0025: DIV0026: POP0027: BR_OE 3 (3)0028: PUSH 10029: RET0030: PUSH 00031: RET0032: PUSH -10033: PUSH -10034: PUSH -10035: PUSH -10036: PUSH -10037: PUSH -10038: PUSH -10039: PUSH 10040: PUSH -10041: PUSH 40042: PUSH -10043: PUSH -10044: PUSH -10045: PUSH -10046: PUSH -10047: PUSH -10048: PUSH -10049: PUSH -10050: PUSH -10051: PUSH 20052: PUSH -10053: PUSH -10054: PUSH -10055: PUSH -10056: PUSH -10057: PUSH -10058: PUSH -10059: PUSH -10060: PUSH -10061: PUSH -10062: PUSH -10063: PUSH 50064: PUSH -10065: PUSH 40066: PUSH -10067: PUSH 70068: PUSH -10069: PUSH -10070: PUSH 80071: PUSH -10072: PUSH -10073: PUSH -10074: PUSH 30075: PUSH -10076: PUSH -10077: PUSH -10078: PUSH -10079: PUSH 10080: PUSH -10081: PUSH 90082: PUSH -10083: PUSH -10084: PUSH -10085: PUSH -10086: PUSH 30087: PUSH -10088: PUSH -10089: PUSH 40090: PUSH -10091: PUSH -10092: PUSH 20093: PUSH -10094: PUSH -10095: PUSH -10096: PUSH 50097: PUSH -10098: PUSH 10099: PUSH -10100: PUSH -10101: PUSH -10102: PUSH -10103: PUSH -10104: PUSH -10105: PUSH -10106: PUSH -10107: PUSH 80108: PUSH -10109: PUSH 60110: PUSH -10111: PUSH -10112: PUSH -10113: PUSH 810114: DUP0115: PUSH 10116: SWAP0117: CLRFLAGS0118: DIV0119: POP0120: BR_ZE 6 (6)0121: PUSH -10122: ADD0123: OVER0124: NOP0125: JMP -11 (-11)0126: POP0127: PUSH 20128: PUSH 1010129: NOP0130: PUSH 30131: PUSH 1020132: NOP0133: PUSH 50134: PUSH 1030135: NOP0136: PUSH 70137: PUSH 1040138: NOP0139: PUSH 110140: PUSH 1050141: NOP0142: PUSH 130143: PUSH 1060144: NOP0145: PUSH 170146: PUSH 1070147: NOP0148: PUSH 190149: PUSH 1080150: NOP0151: PUSH 230152: PUSH 1090153: NOP0154: PUSH -4.2068999999999998e-120155: PUSH 990156: NOP0157: RET0158: PUSH 1000159: ADD0160: LOAD0161: RET0162: PUSH 10163: PUSH 2000164: NOP0165: PUSH 90166: OVER0167: MUL0168: SWAP0169: DUP0170: PUSH 10171: SWAP0172: CLRFLAGS0173: DIV0174: POP0175: BR_ZE 14 (14)0176: PUSH -10177: ADD0178: DUP20179: ADD0180: LOAD0181: CALL 158 (158)0182: PUSH 2000183: OVER0184: LOAD0185: MUL0186: SWAP0187: NOP0188: JMP -19 (-19)0189: POP0190: POP0191: PUSH 2000192: LOAD0193: PUSH 2230928700194: DIV0195: CALL 14 (14)0196: RET0197: PUSH 10198: PUSH 2000199: NOP0200: PUSH 810201: ADD0202: DUP0203: PUSH 90204: DIV0205: CALL 2 (2)0206: PUSH 10207: SWAP0208: CLRFLAGS0209: DIV0210: POP0211: BR_ZE 13 (13)0212: PUSH -90213: ADD0214: DUP0215: LOAD0216: CALL 158 (158)0217: PUSH 2000218: OVER0219: LOAD0220: MUL0221: SWAP0222: NOP0223: JMP -21 (-21)0224: POP0225: PUSH 2000226: LOAD0227: PUSH 2230928700228: DIV0229: CALL 14 (14)0230: RET0231: PUSH 10232: PUSH 2000233: NOP0234: DUP0235: PUSH 30236: DIV0237: TRUNC0238: OVER0239: PUSH 30240: MUL0241: SUB0242: PUSH 30243: MUL0244: SWAP0245: PUSH 270246: MUL0247: ADD0248: DUP0249: PUSH 00250: ADD0251: LOAD0252: CALL 158 (158)0253: PUSH 2000254: OVER0255: LOAD0256: MUL0257: SWAP0258: NOP0259: DUP0260: PUSH 10261: ADD0262: LOAD0263: CALL 158 (158)0264: PUSH 2000265: OVER0266: LOAD0267: MUL0268: SWAP0269: NOP0270: DUP0271: PUSH 20272: ADD0273: LOAD0274: CALL 158 (158)0275: PUSH 2000276: OVER0277: LOAD0278: MUL0279: SWAP0280: NOP0281: DUP0282: PUSH 90283: ADD0284: LOAD0285: CALL 158 (158)0286: PUSH 2000287: OVER0288: LOAD0289: MUL0290: SWAP0291: NOP0292: DUP0293: PUSH 100294: ADD0295: LOAD0296: CALL 158 (158)0297: PUSH 2000298: OVER0299: LOAD0300: MUL0301: SWAP0302: NOP0303: DUP0304: PUSH 110305: ADD0306: LOAD0307: CALL 158 (158)0308: PUSH 2000309: OVER0310: LOAD0311: MUL0312: SWAP0313: NOP0314: DUP0315: PUSH 180316: ADD0317: LOAD0318: CALL 158 (158)0319: PUSH 2000320: OVER0321: LOAD0322: MUL0323: SWAP0324: NOP0325: DUP0326: PUSH 190327: ADD0328: LOAD0329: CALL 158 (158)0330: PUSH 2000331: OVER0332: LOAD0333: MUL0334: SWAP0335: NOP0336: DUP0337: PUSH 200338: ADD0339: LOAD0340: CALL 158 (158)0341: PUSH 2000342: OVER0343: LOAD0344: MUL0345: SWAP0346: NOP0347: POP0348: PUSH 2000349: LOAD0350: PUSH 2230928700351: DIV0352: CALL 14 (14)0353: RET0354: PUSH 90355: DUP0356: CALL 2 (2)0357: PUSH 10358: SWAP0359: CLRFLAGS0360: DIV0361: POP0362: BR_ZE 13 (13)0363: PUSH 10364: SUB0365: DUP0366: CALL 162 (162)0367: SWAP0368: DUP0369: CALL 197 (197)0370: SWAP0371: DUP0372: CALL 231 (231)0373: SWAP0374: JMP -19 (-19)0375: POP0376: PUSH 10377: MUL0378: MUL0379: MUL0380: MUL0381: MUL0382: MUL0383: MUL0384: MUL0385: MUL0386: MUL0387: MUL0388: MUL0389: MUL0390: MUL0391: MUL0392: MUL0393: MUL0394: MUL0395: MUL0396: MUL0397: MUL0398: MUL0399: MUL0400: MUL0401: MUL0402: MUL0403: MUL0404: CALL 14 (14)0405: RET0406: DUP0407: PUSH 810408: DIV0409: CALL 14 (14)0410: PUSH 10411: SWAP0412: CLRFLAGS0413: DIV0414: POP0415: BR_ZE 4 (4)0416: POP0417: CALL 354 (354)0418: RET0419: DUP0420: LOAD0421: CALL 2 (2)0422: PUSH 10423: SWAP0424: CLRFLAGS0425: DIV0426: POP0427: BR_ZE 5 (5)0428: PUSH 10429: ADD0430: CALL 406 (406)0431: RET0432: PUSH 90433: DUP0434: CALL 2 (2)0435: PUSH 10436: SWAP0437: CLRFLAGS0438: DIV0439: POP0440: BR_ZE 22 (22)0441: DUP20442: SWAP0443: NOP0444: SWAP0445: OVER0446: PUSH 10447: ADD0448: CALL 406 (406)0449: PUSH 10450: SWAP0451: CLRFLAGS0452: DIV0453: POP0454: BR_ZE 5 (5)0455: POP0456: POP0457: PUSH 10458: RET0459: PUSH -10460: ADD0461: JMP -28 (-28)0462: POP0463: PUSH -10464: SWAP0465: NOP0466: PUSH 00467: RET0468: PUSH 00469: CALL 406 (406)0470: RET0471: PUSH 6.9e-100472: PUSH 2000473: NOP0474: PUSH 00475: DUP0476: PUSH 810477: DIV0478: CALL 2 (2)0479: PUSH 10480: SWAP0481: SUB0482: PUSH 10483: SWAP0484: CLRFLAGS0485: DIV0486: POP0487: BR_ZE 17 (17)0488: DUP0489: LOAD0490: PUSH 2000491: OVER0492: LOAD0493: PUSH 3.14159265358979310494: MUL0495: SWAP0496: MUL0497: PUSH 2.71828182845904510498: ADD0499: SWAP0500: NOP0501: PUSH 10502: ADD0503: JMP -28 (-28)0504: PUSH 2000505: LOAD0506: 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-10pi = 3.1415926535897931e = 2.7182818284590451for i in range(9): for j in range(9): c = grid[i][j] a = (a * pi * c) + ef = ahere is the actual matrix + the solve (there is only one):

final flag: corctf{g3ntly_d0wn_th3_StR3AM_3.0390693652230334e+89_FPU_h4cks}
solve
import mathgrid = [ [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-10pi = 3.1415926535897931e = 2.7182818284590451for i in range(9): for j in range(9): c = grid[i][j] a = (a * pi * c) + ef = afloat_str = f"{f:.17g}"flag = f"corctf{{g3ntly_d0wn_th3_StR3AM_{float_str}_FPU_h4cks}}"print(flag)