Intro

The goal of this article is to solve one of the ‘hard’ reverse engineering challenges from 2026 DCTF called ‘Camels and Dragons’

The challenge states:

“Dragon’s Solitaire is a card game played with 5 suits (R|W|B|Y|G) of 14 ranks (0-13) and 11 stacks. The game starts with 7 cards in each of the first 10 stacks. The goal is to burn all the cards respecting the following rules:

1. You can BURN the top card of a stack if its rank is the smallest among all cards of the same suit still in play.

2. You can MOVE the top card of one stack onto another stack if the destination is empty OR the destination top card has the same suit and a rank differing by at most 1.

The game looks something like this:

Camels and Dragons

If this looks like your kind of solitaire, feel free to try solving it by hand. Personally, I’d rather let the computer do the work.

Instead of spending hours searching for the right sequence of moves, why not make the game do exactly what we want? Even better, why not make it solve every game, every time?

That sounds much more fun, no?

Preliminary analysis

Let’s start by looking into what this binary actually is (it’s downloadable from here):

~/Downloads
❯ file dragon
dragon: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bea2e91325bbc9837d8c6fe8a1322b04ce35bcb6, for GNU/Linux 3.2.0, with debug_info, not stripped

Hmm, interesting. Not stripped, with debug info present — that could make things easier later.

Let’s look at what it links:

❯ ldd dragon
        linux-vdso.so.1 (0x00007f5ed15d5000)
        libm.so.6 => /usr/lib/libm.so.6 (0x00007f5ed1337000)
        libc.so.6 => /usr/lib/libc.so.6 (0x00007f5ed1000000)
        /lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f5ed15d7000)

Nothing interesting, really.

Finally, before we open the binary in Binary Ninja, let’s look at the debug symbols:

~/Downloads [🐍 v3.14.6]
❯ readelf --debug-dump=info dragon | less

Contents of the .debug_info section:

  Compilation Unit @ offset 0:
   Length:        0x43 (32-bit)
   Version:       3
   Abbrev Offset: 0
   Pointer Size:  8
 <0><b>: Abbrev Number: 1 (DW_TAG_compile_unit)
    <c>   DW_AT_stmt_list   : 0
    <10>   DW_AT_low_pc      : 0x54ef0
    <18>   DW_AT_high_pc     : 0x54f19
    <20>   DW_AT_name        : (indirect string, offset: 0): std_exit.ml
    <24>   DW_AT_comp_dir    : (indirect string, offset: 0xc): /home/opam/.opam/5.2/.opam-switch/build/ocaml-base-compiler.5.2.1/stdlib
    <28>   DW_AT_producer    : (indirect string, offset: 0x55): GNU AS 2.42
    <2c>   DW_AT_language    : 32769    (MIPS assembler)
 <1><2e>: Abbrev Number: 2 (DW_TAG_subprogram)
    <2f>   DW_AT_name        : (indirect string, offset: 0x61): camlStd_exit.entry
    <33>   DW_AT_external    : 1
    <34>   DW_AT_type        : <0x45>
    <35>   DW_AT_low_pc      : 0x54ef0
    <3d>   DW_AT_high_pc     : 0x54f19
 <1><45>: Abbrev Number: 3 (DW_TAG_unspecified_type)
 <1><46>: Abbrev Number: 0
  Compilation Unit @ offset 0x47:
   Length:        0x7c (32-bit)
   Version:       3
   Abbrev Offset: 0x28
   Pointer Size:  8
 <0><52>: Abbrev Number: 1 (DW_TAG_compile_unit)
    <53>   DW_AT_stmt_list   : 0x41
    <57>   DW_AT_ranges      : 0
    <5b>   DW_AT_name        : (indirect string, offset: 0x74): camlinternalFormatBasics.ml
    <5f>   DW_AT_comp_dir    : (indirect string, offset: 0xc): /home/opam/.opam/5.2/.opam-switch/build/ocaml-base-compiler.5.2.1/stdlib
    <63>   DW_AT_producer    : (indirect string, offset: 0x55): GNU AS 2.42
                                    ....

Well, there it is: /home/opam/.opam/5.2/.opam-switch/build/ocaml-base-compiler.5.2.1/stdlib. It’s an OCaml binary.

That could certainly make things interesting, since OCaml, even in native code compile mode, still ships/embeds a lightweight runtime that is in charge of managing things like garbage collection, etc.

Loading the binary into Binary Ninja

If we load the binary into Binary Ninja, we can first reconfirm what we already know.

Triage

About what we expected.

Let’s search for some common entry points like main, and since we know this is a game, maybe game too. Let’s see where this gets us!

The Python API

We could do everything through the GUI, but Binja also has a pretty awesome Python API, so how about we use that instead? It will probably be both easier and faster.

Searching for the entry point

To search for main and game functions, we can do the following:

for func in bv.functions:
	if any(part in func.name.lower().strip() for part in ('main', 'game')): 
		print(f"{func.name!r} with range: {func.address_ranges!r}")
Function search results
Full search output
'main' with range: [<0x452890-0x4528a9>]
'camlDragon.game_loop_589' with range: [<0x455bd0-0x455cde>, <0x455ce0-0x455db1>, <0x455db4-0x455deb>, <0x455e24-0x455e4e>, <0x455e50-0x455f4e>, <0x455f50-0x455f61>, <0x455f64-0x456061>, <0x456064-0x456075>, <0x456078-0x4560d2>]
'camlStdlib__Domain.cpu_relax_305' with range: [<0x476ed0-0x476ee2>]
'camlStdlib__Domain.some_394' with range: [<0x476ef0-0x476ef1>]
'camlStdlib__Domain.is_some_397' with range: [<0x476f00-0x476f14>]
'camlStdlib__Domain.unsafe_get_400' with range: [<0x476f20-0x476f21>]
'camlStdlib__Domain.create_dls_413' with range: [<0x476f30-0x476f65>]
'camlStdlib__Domain.add_parent_key_492' with range: [<0x476f70-0x476fc8>]
'camlStdlib__Domain.new_key_495' with range: [<0x476fd0-0x47707e>]
'camlStdlib__Domain.maybe_grow_502' with range: [<0x477080-0x4770be>, <0x4770c0-0x477172>, <0x477174-0x477196>]
'camlStdlib__Domain.compute_new_size_506' with range: [<0x4771a0-0x4771be>]
'camlStdlib__Domain.set_510' with range: [<0x4771c0-0x477222>, <0x477224-0x477248>]
'camlStdlib__Domain.array_compare_and_set_592' with range: [<0x477250-0x4772f0>]
'camlStdlib__Domain.get_599' with range: [<0x4772f0-0x47738a>, <0x47738c-0x47740e>, <0x477410-0x4774af>]
'camlStdlib__Domain.get_initial_keys_613' with range: [<0x4774b0-0x4774cd>]
'camlStdlib__Domain.fun_806' with range: [<0x4774d0-0x47754c>]
'camlStdlib__Domain.set_initial_keys_686' with range: [<0x477550-0x47755f>]
'camlStdlib__Domain.fun_810' with range: [<0x477560-0x47756c>]
'camlStdlib__Domain.get_id_694' with range: [<0x477570-0x477574>]
'camlStdlib__Domain.self_698' with range: [<0x477580-0x477595>]
'camlStdlib__Domain.is_main_domain_701' with range: [<0x4775a0-0x4775c6>]
'camlStdlib__Domain.fun_815' with range: [<0x4775d0-0x4775d6>]
'camlStdlib__Domain.before_first_spawn_707' with range: [<0x4775e0-0x47768e>]
'camlStdlib__Domain.new_f_711' with range: [<0x477690-0x4776e1>]
'camlStdlib__Domain.do_before_first_spawn_714' with range: [<0x4776f0-0x477776>]
'camlStdlib__Domain.fun_824' with range: [<0x477780-0x477786>]
'camlStdlib__Domain.fun_828' with range: [<0x477790-0x477796>]
'camlStdlib__Domain.fun_826' with range: [<0x4777a0-0x4777a8>]
'camlStdlib__Domain.at_exit_721' with range: [<0x4777b0-0x47783d>]
'camlStdlib__Domain.new_exit_724' with range: [<0x477840-0x477891>]
'camlStdlib__Domain.do_at_exit_727' with range: [<0x4778a0-0x4778ea>]
'camlStdlib__Domain.spawn_730' with range: [<0x4778f0-0x477a1b>]
'camlStdlib__Domain.body_735' with range: [<0x477a20-0x477a85>, <0x477ac8-0x477af4>]
'camlStdlib__Domain.join_742' with range: [<0x477b00-0x477b76>]
'camlStdlib__Domain.loop_745' with range: [<0x477b80-0x477bb9>, <0x477bbc-0x477bcb>]
'camlStdlib__Domain.recommended_domain_count_751' with range: [<0x477bd0-0x477be3>]
'camlStdlib__Domain.entry' with range: [<0x477bf0-0x4781ff>]
'caml_domain_initialize_default' with range: [<0x49dfe0-0x49dfe5>]
'caml_domain_external_interrupt_hook_default' with range: [<0x49e0a0-0x49e0a5>]
'caml_domain_stop_default' with range: [<0x49e0b0-0x49e0b5>]
'decrement_stw_domains_still_processing' with range: [<0x49e300-0x49e30c>, <0x49e310-0x49e373>]
'caml_reset_domain_lock' with range: [<0x49e440-0x49e477>]
'caml_init_domain_self' with range: [<0x49e480-0x49e4af>]
'caml_ml_domain_id' with range: [<0x49e4b0-0x49e4ca>]
'caml_global_barrier_num_domains' with range: [<0x49e5c0-0x49e5cb>]
'domain_create' with range: [<0x49e980-0x49e9be>, <0x49e9c0-0x49ec98>, <0x49eca0-0x49eceb>]
'caml_init_domains' with range: [<0x49ecf0-0x49ede6>]
'domain_thread_func' with range: [<0x49edf0-0x49f0bd>, <0x49f0c0-0x49f2dc>, <0x49f2e0-0x49f2fb>, <0x49f300-0x49f313>, <0x49f318-0x49f34c>]
'caml_try_run_on_all_domains_with_spin_work' with range: [<0x49f7d0-0x49f971>, <0x49f978-0x49fa5e>]
'caml_try_run_on_all_domains' with range: [<0x49fa60-0x49fa7d>]
'caml_try_run_on_all_domains_async' with range: [<0x49fa80-0x49fa9a>]
'caml_domain_spawn' with range: [<0x49fb40-0x49fc1c>, <0x49fc20-0x49fc8d>, <0x49fc90-0x49fcf1>, <0x49fcf8-0x49fd5d>]
'caml_domain_is_multicore' with range: [<0x49fe40-0x49fe6e>]
'caml_acquire_domain_lock' with range: [<0x49fe70-0x49feab>]
'caml_release_domain_lock' with range: [<0x49fee0-0x49ff21>]
'caml_domain_terminating' with range: [<0x49ff70-0x49ff8e>]
'caml_domain_is_terminating' with range: [<0x49ff90-0x49ffa1>]
'caml_ml_domain_cpu_relax' with range: [<0x49ffb0-0x49ffdb>]
'caml_domain_dls_set' with range: [<0x49ffe0-0x4a000a>]
'caml_domain_dls_get' with range: [<0x4a0010-0x4a0025>]
'caml_domain_dls_compare_and_set' with range: [<0x4a0030-0x4a006a>]
'caml_recommended_domain_count' with range: [<0x4a0070-0x4a0105>, <0x4a0108-0x4a0119>]
'caml_alloc_main_stack' with range: [<0x4a3940-0x4a3986>, <0x4a3990-0x4a3997>]
'caml_reset_domain_alloc_stats' with range: [<0x4a5f50-0x4a5f76>]
'stw_cycle_all_domains' with range: [<0x4adfe0-0x4ae239>, <0x4ae240-0x4ae256>, <0x4ae260-0x4ae272>, <0x4ae278-0x4ae298>, <0x4ae2a0-0x4ae35b>, <0x4ae360-0x4ae36a>, <0x4ae370-0x4ae442>, <0x4ae448-0x4ae481>]
'is_complete_phase_sweep_and_mark_main' with range: [<0x4ae4f0-0x4ae4fd>, <0x4ae500-0x4ae554>]
'caml_memprof_new_domain' with range: [<0x4b2440-0x4b24cb>, <0x4b24d0-0x4b2535>, <0x4b2538-0x4b2546>, <0x4b2550-0x4b2556>]
'caml_memprof_delete_domain' with range: [<0x4b2560-0x4b25d5>, <0x4b25d8-0x4b25f3>]
'caml_memprof_main_thread' with range: [<0x4b26f0-0x4b2700>]
'caml_empty_minor_heap_domain_clear' with range: [<0x4b33d0-0x4b342e>]
'caml_try_empty_minor_heap_on_all_domains' with range: [<0x4b3c00-0x4b3c3e>]
'caml_main' with range: [<0x4bf260-0x4bf28c>]

We did get some junk in between, like domain_thread_func, but we also got some interesting hits like caml_main, main and camlDragon.game_loop_589!

Searching for XOR

Before looking into what we’ve already found, let’s do the same for any XOR operation in the binary.

XOR is one of the most common operations used to hide flags in CTF binaries. It’s cheap, reversible and a decent enough way to obfuscate data quickly.

Again, we’ll search for XOR operations via the Python API:

for func in bv.functions:
    if not func.medium_level_il or not func.name.startswith('camlDragon'): # If function has MLIL and it starts with 'camlDragon' -> This is a guess, see the function we've found above
        continue
    for insn in func.medium_level_il.instructions: # For every instruction in function
        if insn.operation == MediumLevelILOperation.MLIL_SET_VAR and \
           insn.src.operation == MediumLevelILOperation.MLIL_XOR: # If variable is being set and there's an XOR operation
            left, right = insn.src.left, insn.src.right
            self_xor = (left.operation == MediumLevelILOperation.MLIL_VAR and
                         right.operation == MediumLevelILOperation.MLIL_VAR and
                         left.src == right.src)
            if not self_xor: # We are not interested in A ^ A only A ^ B 
                print(f"[{func.name}] 0x{insn.address:x}: {insn}")
[camlDragon.ck_488] 0x4557e3: rax_2 = rax_1 ^ rdi_4
[camlDragon.game_loop_589] 0x455d28: rax_13 = rax_12 ^ rbx_18

This gives us some pretty strong hits. We should definitely look into camlDragon.game_loop_589, camlDragon.ck_488, main and caml_main.

Exploring entry points

Let’s start with the main entry point.

main entry point
scroll = zoom · drag = pan · double-click = reset

Now, if you’re familiar with reverse engineering non-C/C++ programs, what you see above is probably not a huge surprise. The main entry point usually contains just calls to runtime-specific entry points.

Following it down, caml_main contains:

caml_main
scroll = zoom · drag = pan · double-click = reset

And caml_startup_common

caml_startup_common
scroll = zoom · drag = pan · double-click = reset

And so on and so on.

What is happening here is that OCaml is initializing its runtime and everything it needs during program execution, before handing control over to our actual entry point.

We could follow this call stack probably for quite a long time before we actually get to what we are interested in. So how about we look at other interesting things we’ve found, like camlDragon.game_loop_589, and work backwards if needed?

Looking at the game loop

For camlDragon.game_loop_589, the main game loop, I’ll provide you with three flow diagrams — one in MLIL, one in disassembly and one in Pseudo-C, and I’ll explain why as we go along.

Here they are (Don’t worry if the graphs look intimidating—we’re only interested in two branches):

Disassembly flow diagram
Disassembly view of camlDragon.game_loop_589
scroll = zoom · drag = pan · double-click = reset
MLIL flow diagram
MLIL view of camlDragon.game_loop_589
scroll = zoom · drag = pan · double-click = reset
Pseudo-C flow diagram
Pseudo-C view of camlDragon.game_loop_589
scroll = zoom · drag = pan · double-click = reset

As you go from disassembly to Pseudo-C, each step is less verbose and more readable, but also, since Binary Ninja tries to guess what the original code looked like, it could miss some important instructions that are only visible in the lower representations!

I’ve also taken the liberty of coloring and annotating each block of the Pseudo-C representation, as well as fixing some types. This is mostly just laborious and, besides that, not very interesting.

With that being said, here is the annotated Pseudo-C flow graph of camlDragon.game_loop_589:

Annotated Pseudo-C view of camlDragon.game_loop_589
scroll = zoom · drag = pan · double-click = reset

Blue blocks represent decisions which won’t be very interesting to us. Orange blocks represent important branching inside the main game loop. Red blocks represent important branching which we could use to solve this CTF.

So, let’s look at the first red block, reading the diagram top to bottom.

Which way?

The first red block represents the main decision point in the game loop.

It makes a call to OCaml’s List.for_all.

This “list”, in this case, is your current deck of cards!

Looking at the documentation for List.for_all, we find the following:

for_all f [a1; ...; an] checks if all elements of the list satisfy the predicate f. That is, it returns (f a1) && (f a2) && ... && (f an) for a non-empty list and true if the list is empty.

So the whole branch hinges on a single question: does every element of our deck satisfy the predicate f?

Let’s look at what that predicate actually does. Binary Ninja was kind enough to split it out into its own function:

// camlDragon.for_all_predicate @ 0x4560e0
return (arg1 == 1) * 2 + 1

At first glance this is cryptic, but remember that we’re staring at OCaml’s tagged value representation. An OCaml int n is stored as 2n + 1, false is 1, true is 3 and — crucially — the empty list [] is also represented as 1.

With that in mind, (arg1 == 1) * 2 + 1 reads as:

  • if arg1 == 1 → return 3 (true)
  • otherwise → return 1 (false)

In other words, the predicate is simply:

fun stack -> stack = []

Each element of our “deck” is a stack, and the predicate asks “is this stack empty?”. List.for_all then asks that of every stack at once, so the full check is:

Are all of the stacks empty?

Which is exactly the win condition straight out of the challenge description — burn all the cards.

Now the branch makes sense. Reading it back in the flow graph:

  • all stacks empty (for_all returns true) → we’ve won, and the loop falls through into the block we actually care about.
  • any stack still holds cards → we keep playing: render the board, read a line of input, and dispatch it to do_move / do_burn.

So this red block is the fork between “still playing” and “you won”. And whatever sits on the winning side is exactly where our flag is going to come from…

The winning branch

Once we’ve emptied every stack, the loop falls into the block that builds and prints our reward.

The logic is short and looks roughly like this:

  • It grabs two arraysdata_4e7480 and data_4e7488 — and works out the total output length as len(arr0) + len(arr1) (in the decompilation this shows up as ... - 1, which is just OCaml’s tagged-integer arithmetic, not a real off-by-one).
  • It then walks a single index i from the start to that length, appending one character at a time into an OCaml Buffer.
  • For each i, the parity of the index decides which array to read from: even positions come from data_4e7480, odd positions from data_4e7488 (each array advancing every other step). In effect, the two arrays are interleaved back into a single stream.
  • The byte it just pulled is then XORed with camlDragon.ck_488(i)this is where our ck function from the XOR hunt finally shows up — passed through Char.chr, and appended to the buffer.
  • Finally the buffer is joined into a string and printed.

Boiled down to pseudo-code, the winning branch is essentially:

let buf = Buffer.create n in
for i = 0 to (Array.length arr0 + Array.length arr1) - 1 do
  let byte = if i mod 2 = 0 then arr0.(i / 2) else arr1.(i / 2) in
  Buffer.add_char buf (Char.chr (byte lxor ck_488 i))
done;
print_string (Buffer.contents buf)

So the flag isn’t stored anywhere in plaintext.

It’s split across two interleaved arrays and XOR-masked with an index-dependent keystream from camlDragon.ck_488, only ever reconstructed once you actually win the game.

The XOR key generator

The last piece is camlDragon.ck_488 — the function producing the key byte for each position.

XOR Key Generator
scroll = zoom · drag = pan · double-click = reset

Decompiled, it’s a one-liner:

// camlDragon.ck_488 @ 0x4557f0
return (((arg1 * 0xad - 0x36) ^ ((arg1 - 1) * (arg1 s>> 1) * 3 + 0x23)) | 1) & 0x1ff

There’s a lot of tagged-integer noise here (arg1 is the OCaml-tagged index 2i + 1, and s>> 1 just untags it back to i), but the shape is straightforward:

  • it takes the index i and nothing else — no board state, no randomness, no time;
  • it mixes that index two different ways — substituting arg1 = 2i + 1 and untagging, those are 346i + 119 and 6i² + 35 — and XORs the two together;
  • the trailing | 1 and & 0x1ff just clamp the result back into a valid OCaml-tagged byte (odd, 9 bits), i.e. a single 0–255 key byte.

The important word is pure: ck_488 is a deterministic function of the position alone. The same index always yields the same key byte, regardless of anything happening in the game.

And that’s really the whole ballgame. The main difficulty here is that if you’d only looked at the pseudo-C representation, you’d have missed the important instructions hiding in the win branch of the game loop.

Now that we know what the win branch looks like, how do we actually solve this?

The dynamic solution

If you’re on an x86-64 Linux machine and you can execute the binary, then there’s literally a single-line solution!

Remember when we talked about how the game loop effectively has two branches — the actual gameplay loop and the secret decoding loop — and how we pick which one to run based on whether we still have cards in hand?

What if we could patch that branch so that it always takes the XOR branch, no matter whether we still have cards left in hand or not?

Zoomed-in view of the deciding branch

The branch deciding which path to take lives at 0x00455c0f, as you can see from the screenshot above.

The Binary Ninja Python API has a binaryview.never_branch call.

It takes the address where the branch instruction lives, plus an optional architecture parameter.

So all we have to do is run this Python one-liner:

bv.never_branch(0x455c0f)

By making this API call, we’ve essentially cut off the actual gameplay branch and now always take the “win” branch, no matter how many cards we have left in hand!

Patched branch
scroll = zoom · drag = pan · double-click = reset

All we have to do now is export the patched binary and run it!

Exporting the patched binary
~/Downloads [🐍 v3.14.6]
❯ chmod +x dragon_patched_demo

~/Downloads [🐍 v3.14.6]
❯ ./dragon_patched_demo

ALL CARDS DESTROYED -- YOU WIN!
<flag>
Flag

The flag spit out is dctf{7H3R3_15_4_B1G_5h4RK_1n_JUV15_dUNg30N}

There it is! It works. Right after running the binary, we immediately enter the win condition and get our flag!

The static solution

But what if you’re on a system where you can’t just patch and run the binary, like a Windows system?

Or maybe you just don’t want to execute a random, unknown binary? Are you out of luck then?

No, not really — we just have a couple more steps to take!

Remember how we said there are two arrays storing the secret data? data_4e7480 (0x004e7480) and data_4e7488 (0x004e7488), one for the even output positions and one for the odd.

Now, by default they’re uninitialized — that is, the application fills them with the actual secret data at startup, rather than the data being hardcoded into the .data section.

To start, let’s find the cross references to these two arrays:

for ref in bv.get_code_refs(0x004e7480):
	print(f"Referenced from function {ref.function.name} at {hex(ref.address)}")
Referenced from function camlDragon.game_loop_589 at 0x455c53
Referenced from function camlDragon.game_loop_589 at 0x455cbe
Referenced from function camlDragon.entry at 0x456333

And for the other one:

for ref in bv.get_code_refs(0x004e7488):
	print(f"Referenced from function {ref.function.name} at {hex(ref.address)}")
Referenced from function camlDragon.game_loop_589 at 0x455c40
Referenced from function camlDragon.game_loop_589 at 0x455cfb
Referenced from function camlDragon.entry at 0x456363

We already know about the camlDragon.game_loop_589 function, and it makes sense that it’s referenced there — we’ve seen it.

But camlDragon.entry is a new one!

Fill arrays
scroll = zoom · drag = pan · double-click = reset

The entry function

I’m going to fast-forward a bit here, since it’s mostly about things we’ve already covered.

The highlighted instructions above copy/clone camlDragon.27 into data_4e7480 and camlDragon.28 into data_4e7488, but this is once again a bit obfuscated by the OCaml runtime and how OCaml moves data around — it’s nothing too hard, though; you just have to follow the calls to see what it’s doing.

Anyway, actual data lives in camlDragon.27 and camlDragon.28!

004e7a58  camlDragon.28:
004e7a58                          3f 01 00 00 00 00 00 00          ?.......
004e7a60  11 00 00 00 00 00 00 00 ef 01 00 00 00 00 00 00  ................
004e7a70  c3 00 00 00 00 00 00 00 cf 00 00 00 00 00 00 00  ................
004e7a80  cf 01 00 00 00 00 00 00 af 00 00 00 00 00 00 00  ................
004e7a90  6b 01 00 00 00 00 00 00 fb 01 00 00 00 00 00 00  k...............
004e7aa0  03 00 00 00 00 00 00 00 71 00 00 00 00 00 00 00  ........q.......
004e7ab0  a1 01 00 00 00 00 00 00 37 00 00 00 00 00 00 00  ........7.......
004e7ac0  11 01 00 00 00 00 00 00 e5 00 00 00 00 00 00 00  ................
004e7ad0  59 00 00 00 00 00 00 00 d3 00 00 00 00 00 00 00  Y...............
004e7ae0  d5 01 00 00 00 00 00 00 dd 00 00 00 00 00 00 00  ................
004e7af0  83 00 00 00 00 00 00 00 f5 00 00 00 00 00 00 00  ................
004e7b00  00 5b 00 00 00 00 00 00                          .[......
004e7b08  camlDragon.27:
004e7b08                          9d 00 00 00 00 00 00 00          ........
004e7b10  f9 01 00 00 00 00 00 00 ab 01 00 00 00 00 00 00  ................
004e7b20  f9 00 00 00 00 00 00 00 41 00 00 00 00 00 00 00  ........A.......
004e7b30  3f 01 00 00 00 00 00 00 47 01 00 00 00 00 00 00  ?.......G.......
004e7b40  b1 01 00 00 00 00 00 00 b1 00 00 00 00 00 00 00  ................
004e7b50  ff 01 00 00 00 00 00 00 97 00 00 00 00 00 00 00  ................
004e7b60  21 01 00 00 00 00 00 00 d3 01 00 00 00 00 00 00  !...............
004e7b70  03 00 00 00 00 00 00 00 73 00 00 00 00 00 00 00  ........s.......
004e7b80  93 00 00 00 00 00 00 00 f7 01 00 00 00 00 00 00  ................
004e7b90  ef 01 00 00 00 00 00 00 37 01 00 00 00 00 00 00  ........7.......
004e7ba0  e7 00 00 00 00 00 00 00 45 01 00 00 00 00 00 00  ........E.......
004e7bb0  bb 00 00 00 00 00 00 00 00 0b 00 00 00 00 00 00  ................

There it is!

All we have to do now is recreate the decryption loop!

Python solver

Two small things to keep in mind while porting the decryption loop to Python:

  • The values above are stored in OCaml’s tagged form (2n + 1), so every element — and the output of ck_488 — needs a single right shift (>> 1) to get the real byte back.
  • camlDragon.27 feeds the even output positions and camlDragon.28 the odd ones, so we interleave them exactly the way the game loop did.

Rewriting ck_488 in its untagged form and dropping the two arrays straight from the dump, the whole solver is just:

# camlDragon.27 -> data_4e7480 (even output positions), 22 elements
# camlDragon.28 -> data_4e7488 (odd output positions),  21 elements
# Values are exactly as they sit in memory: OCaml-tagged ints (2*n + 1).
even = [0x9d, 0x1f9, 0x1ab, 0xf9, 0x41, 0x13f, 0x147, 0x1b1, 0xb1, 0x1ff, 0x97,
        0x121, 0x1d3, 0x03, 0x73, 0x93, 0x1f7, 0x1ef, 0x137, 0xe7, 0x145, 0xbb]
odd  = [0x13f, 0x11, 0x1ef, 0xc3, 0xcf, 0x1cf, 0xaf, 0x16b, 0x1fb, 0x03, 0x71,
        0x1a1, 0x37, 0x111, 0xe5, 0x59, 0xd3, 0x1d5, 0xdd, 0x83, 0xf5]

untag = lambda v: v >> 1  # OCaml int n is stored as 2n + 1

def ck(i):
    # camlDragon.ck_488, rewritten in untagged form
    return ((((346 * i + 119) ^ (6 * i * i + 35)) | 1) & 0x1ff) >> 1

arr0 = [untag(v) for v in even]
arr1 = [untag(v) for v in odd]

flag = []
for i in range(len(arr0) + len(arr1)):
    byte = arr0[i // 2] if i % 2 == 0 else arr1[i // 2]
    flag.append(chr(byte ^ ck(i)))

print("".join(flag))

And there it is again, exactly the same flag, recovered completely statically.

Conclusion

The main difficulty in this CTF comes from OCaml specifics — tagged values, and a runtime that unintentionally obfuscates some things and makes them harder to find. Other than that, it’s pretty standard stuff!