Fighting dragons and riding camels (DCTF26)
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:
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.
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}")
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.
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:
And caml_startup_common
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
MLIL flow diagram
Pseudo-C flow diagram
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:
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→ return3(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_allreturnstrue) → 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 arrays —
data_4e7480anddata_4e7488— and works out the total output length aslen(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
ifrom the start to that length, appending one character at a time into an OCamlBuffer. - For each
i, the parity of the index decides which array to read from: even positions come fromdata_4e7480, odd positions fromdata_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 ourckfunction from the XOR hunt finally shows up — passed throughChar.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.
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
iand nothing else — no board state, no randomness, no time; - it mixes that index two different ways — substituting
arg1 = 2i + 1and untagging, those are346i + 119and6i² + 35— and XORs the two together; - the trailing
| 1and& 0x1ffjust clamp the result back into a valid OCaml-tagged byte (odd, 9 bits), i.e. a single0–255key 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?
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!
All we have to do now is export the patched binary and run it!
~/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!
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 ofck_488— needs a single right shift (>> 1) to get the real byte back. camlDragon.27feeds the even output positions andcamlDragon.28the 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!




