NSEC25 - Hit the Jackpot

nsec25northsecnsec
Zuyoutoki

NorthSec 2025's theme was a heist on a luxury cruise ship. As one can expect from a luxury cruise ship, there was a casino on board. Who were we not to try our luck?

Hit the Jackpot was a 34 points track spread across 9 challenges and split in 4 sub-tracks. Unfortunately, we only managed to solve 5 challenges during the event, but I took the liberty to finish the 6th challenge afterward and include it in the writeup too.

I wrote this right after NorthSec last year, but forgot to post it. Better late than never :)

1/9 [reels] 1/1 (1 point)

The track begins at the slot machine, on the casino floor. Standing alight, the machine beckons patrons of the cruise ship to insert their access card and try their luck.

Once inserted, the player may select the stakes at which they want to play.

After playing for a few rounds, you notice that one of the displayed symbols on the reels seems to be letters, starting with FLAG; this is probably the first flag.

There we go, maybe we haven't won big, but we aren't leaving empty handed.

2/9 [rng] 1/4 (2 points)

Play a couple games on the white stakes and you'll notice something strange: it seems like they are all the same. We can exploit that by playing once to find which rounds are winning, then betting the minimum on a losing round and the maximum on a winning round.

In our experience, it went like this:

  • Bet $1 on round 1, 2 and 3;
  • Bet $47 on round 4, you'll win $141 with three cherries;
  • Bet $1 on round 5, 6 and 7;
  • Bet $138 on round 8, you'll win $414 with three cherries;
  • Bet $1 on round 9 and 10; and finally
  • Bet $412 on round 11, you'll win $2060 with three bars and reach the maximum wins for the white stakes.

The flag is then revealed:

3/9 [web] 1/2 (1 point)

This one was pretty straightforward. Upon closer inspection of the slot machine, we noticed a sheet taped to the side with a big nice QR code at the bottom. The QR code reads: WL-ef2582640681ad8a1ac57583dcf98691ad. Beneath the QR code was the URL: http://wonderlight.ctf.

Upon visiting the website, we are greeted with what looks like a support website for slot machine owners. Entering the model number, we can see some technical specifications and the flag:

4/9 [web] 2/2 (1 point)

On the same page where we found the previous flag, we can see that a service password is required to download the firmware. Using the power of right-click > view source code, we can reveal the service password written in cleartext and validated client-side:

We can also copy the download link and retrieve the firmware. When the download finishes, the flag can be found in the README.md file inside the archive.

5/9 [reverse] 1/2 (3 points)

Now we get into the juicy part (and the last challenge we managed to solve during the CTF).

The firmware we downloaded previously is actually an AppImage binary, a portable executable for Linux. They are usually shipped with all the required dependencies for the program to run properly on any system.

Thankfully, there is a command to extract the files from an AppImage:

Bash
./slot-machine.AppImage --appimage-extract

This command will drop an AppDir directory and a squashfs-root symlink that points to that directory. We can ignore the symlink and dive into AppDir/.

Within AppDir/, a few things stand out:

  • binaries named AppRun and sharun
  • .env file
  • and a couple directories.

After some searching, we learned that sharun is a tool that can "run dynamically linked ELF binaries everywhere", according to its GitHub repo description. It works by generating a binary that will have all of its dependencies in a shared directory and does some magic to make it all work. I think that removing layers of abstraction on top of the binary makes the reversing process easier, so that's what I did.

I managed to run the actual binary using the following command:

Bash
LD_LIBRARY_PATH=./shared/lib/ ./shared/bin/slot-machine

After playing with the binary to see how it worked, we ran strings and found multiple instances of the string tauri throughout the file. We investigated that path and tried to find tools to extract assets from Tauri binaries, but with no success.

To look for interesting functions related to Tauri, we loaded GDB and ran:

Text Only
info functions tauri

A lot of functions were displayed, but reading through them revealed an interesting one:

Text Only
fn tauri_utils::assets::EmbeddedAssets::get(*mut tauri_utils::assets::EmbeddedAssets, *mut tauri_utils::assets::AssetKey)

Disassembling the function gives us the following:

Text Only
gef➤  disas tauri_utils::assets::EmbeddedAssets::get
Dump of assembler code for function _ZN11tauri_utils6assets14EmbeddedAssets3get17h1046c86bf30ab87eE:
   0x0000000100ea8260 <+0>:     sub    rsp,0x68
   0x0000000100ea8264 <+4>:     mov    QWORD PTR [rsp+0x8],rdx
   0x0000000100ea8269 <+9>:     mov    QWORD PTR [rsp+0x10],rsi
   0x0000000100ea826e <+14>:    mov    rax,rdi
   0x0000000100ea8271 <+17>:    mov    rdi,QWORD PTR [rsp+0x8]
   0x0000000100ea8276 <+22>:    mov    QWORD PTR [rsp+0x18],rax
   0x0000000100ea827b <+27>:    mov    QWORD PTR [rsp+0x20],rax
   0x0000000100ea8280 <+32>:    mov    QWORD PTR [rsp+0x58],rsi
   0x0000000100ea8285 <+37>:    mov    QWORD PTR [rsp+0x60],rdi
   0x0000000100ea828a <+42>:    call   QWORD PTR [rip+0xbf7408]        # 0x101a9f698
   0x0000000100ea8290 <+48>:    mov    rdi,QWORD PTR [rsp+0x10]
   0x0000000100ea8295 <+53>:    mov    rsi,rax
   0x0000000100ea8298 <+56>:    call   QWORD PTR [rip+0xbf3142]        # 0x101a9b3e0
   0x0000000100ea829e <+62>:    mov    rsi,rax
   0x0000000100ea82a1 <+65>:    lea    rdi,[rsp+0x40]
   0x0000000100ea82a6 <+70>:    call   QWORD PTR [rip+0xbe2524]        # 0x101a8a7d0
   0x0000000100ea82ac <+76>:    lea    rdi,[rsp+0x28]
   0x0000000100ea82b1 <+81>:    lea    rsi,[rsp+0x40]
   0x0000000100ea82b6 <+86>:    call   QWORD PTR [rip+0xbf4e3c]        # 0x101a9d0f8
   0x0000000100ea82bc <+92>:    mov    rdi,QWORD PTR [rsp+0x18]
   0x0000000100ea82c1 <+97>:    lea    rsi,[rsp+0x28]
   0x0000000100ea82c6 <+102>:   call   QWORD PTR [rip+0xbe87cc]        # 0x101a90a98
   0x0000000100ea82cc <+108>:   mov    rax,QWORD PTR [rsp+0x20]
   0x0000000100ea82d1 <+113>:   add    rsp,0x68
   0x0000000100ea82d5 <+117>:   ret
End of assembler dump.

This function seems to be used to retrieve an asset by name/id, so we can probably break near the end and dump the extracted asset to the disk. Let's try it out:

Text Only
gef➤  b *tauri_utils::assets::EmbeddedAssets::get+113
Breakpoint 1 at 0x100ea82d1: file src/assets.rs, line 148.
gef➤  c
[ ... snip ... ]
gef➤  dereference $rsp
0x00007fffffff5e30│+0x0000: 0x000000000000000a ("\n"?)   ← $rsp
0x00007fffffff5e38│+0x0008: 0x00007fffffff6058  →  0x000000000000000b ("
                                                                        "?)
0x00007fffffff5e40│+0x0010: 0x00007fffe4001050  →  0x00000001010881ac  →   add BYTE PTR [rax], al
0x00007fffffff5e48│+0x0018: 0x00007fffffff6118  →  0x00000000000004e6
0x00007fffffff5e50│+0x0020: 0x00007fffffff6118  →  0x00000000000004e6
0x00007fffffff5e58│+0x0028: 0x00000000000004e6
0x00007fffffff5e60│+0x0030: 0x0000000101fd8700  →  "<!doctype html>\n<html lang="en">\n    <head>\n   [...]"
0x00007fffffff5e68│+0x0038: 0x00000000000004e6
0x00007fffffff5e70│+0x0040: 0x00000000000004e6
0x00007fffffff5e78│+0x0048: 0x0000000101fd8700  →  "<!doctype html>\n<html lang="en">\n    <head>\n   [...]"

Strings in Rust are a pointer to a struct with a length and a pointer to the data. We can see what looks like a pointer to a length at $rsp+0x8 and another at $rsp+0x18. Dereferencing those pointers, we get the following strings, the first one being the name of the assets being requested and the other being the content:

Text Only
gef➤  x/s *((*(($rsp+0x8) as *mut u64)+0x8) as *mut u64)
0x101fa1810:    "/index.html"
gef➤  x/s *((*(($rsp+0x18) as *mut u64)+0x8) as *mut u64)
0x101fd8700:    "<!doctype html>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"utf-8\" />\n        <!--link rel=\"icon\" href=\"./favicon.png\" /-->\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n        \n\t\t<link rel=\"modulepreload\" href=\"./_app/immutable/entry/start.Cx1_lLtp.js\">\n\t\t<link rel=\"modulepreload\" href=\"./_app/immutable/chunks/FwgTYjBD.js\">\n\t\t<link rel=\"modulepreload\" href=\"./_app/immutable/chunks/DGK3jQbY.js\">\n\t\t<link rel=\"modulepreload\" href=\"./_app/immutable/entry/app.CvAHx8w0.js\">\n\t\t<link rel=\"modulepreload\" href=\"./_app/immutable/chunks/Dfxzfz8y.js\">\n\t\t<link rel=\"modulepreload\" href=\"./_app/immutable/chunks/Y8tDg5xf.js\">\n\t\t<link rel=\"modulepreload\" href=\"./_app/immutable/chunks/C_s4oqxE.js\">\n    </head>\n    <body data-sveltekit-preload-data=\"hover\">\n        <div style=\"display: contents\">\n\t\t\t<script>\n\t\t\t\t{\n\t\t\t\t\t__sveltekit_1oigkoo = {\n\t\t\t\t\t\tbase: new URL(\".\", location).pathname.slice(0, -1)\n\t\t\t\t\t};\n\n\t\t\t\t\tconst element = document.currentScript.parentElement;\n\n\t\t\t\t\tPromise.all([\n\t\t\t\t\t\timport(\"./_app/immutable/entry/start.Cx1_lLtp.js\"),\n\t\t\t\t\t\timport(\"./_app/immutable/entry/app.CvAHx8w0.js\")\n\t\t\t\t\t]).then(([kit, app]) => {\n\t\t\t\t\t\tkit.start(app, element);\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t</script>\n\t\t</div>\n    </body>\n</html>\n"

The casting of Rust variables was a pain to figure out, thankfully we found this stackoverflow post where someone had a similar issue and it helped a lot. We can automate part of the process by adding the following to our .gdbinit:

Text Only
b *tauri_utils::assets::EmbeddedAssets::get+113
command
    x/s *((*(($rsp+0x8) as *mut u64)+0x8) as *mut u64)
    x/s *((*(($rsp+0x18) as *mut u64)+0x8) as *mut u64)
end

It is pretty verbose, but at some point you end up dumping some JS with the flag. It's in the file "/_app/immutable/nodes/0.B3J0M_qK.js":

6/9 [reverse] 2/2 (6 points)

Being lazy is fine, but it came back to bite me in the end. This part of the challenge was not solved during the CTF due to lack of time, but here's how I solved it when I came back home.

First thing first, we have to dump the files for real this time and attempt to understand how it all works out. The forum post refers to the backend, so we need to identify how one talks to the backend in Tauri.

This gdb script worked great to extract the files in an orderly fashion:

Text Only
# dump assets
shell mkdir -p dump
b *tauri_utils::assets::EmbeddedAssets::get+113
commands
    set $filename = *((*(($rsp+0x8) as *mut u64)+0x8) as *mut u64)
    set $content_start = *((*(($rsp+0x18) as *mut u64)+0x8) as *mut u64)
    set $content_end = $content_start+*((*(($rsp+0x18) as *mut u64)) as *mut u64)
    eval "dump binary memory ./dump/%p-%p.bin %ld %ld", $content_start, $content_end, $content_start, $content_end
    eval "shell echo ./dump/%p-%p.bin:%s >> dump.txt", $content_start, $content_end, $filename
end

I tried to add a continue statement at the end, but for some reason it kept hanging after the first file. I have no idea why, so if you do, please let me know :)

The dump.txt file contains all of the filenames associated with the key used in the source code so that we can make sense of how they work together. Navigating through the app is necessary to download all of the files because the function we break on is only called when a file is retrieved.

A good grep -ri tauri ./dump/ helps me find interesting bits to look at, only one file matches: /_app/immutable/chunks/BEFjKjka.js. I beautified it a bit and skipped to the interesting parts:

JavaScript
// ... snip ... //

async function T(t, e = {}, s) {
    return window.__TAURI_INTERNALS__.invoke(t, e, s)
}

// ... snip ... //

async function L(t) {
    p(E, t, !0), p(w, new v, !0), p(g, await T("start_level", {
        level: t,
        timerEvents: d(w)
    }), !0)
}

// ... snip ... //

export {
    v as C, Y as a, P as b, F as c, i as d, j as e, B as f, R as g, W as h, T as i, O as j, y as l, c as m, A as r, L as s
};

Basically, there is a function related to Tauri called invoke that takes a string and a dictionnary as arguments. This seems like a nice way to call function in Rust from Javascript.

To confirm our hypothesis, we can go back in GDB and look for a function called start_level:

Text Only
gef➤  info functions start_level
All functions matching regular expression "start_level":
[ ... snip ...]
File src/lib.rs:
195:    static fn slot_machine_lib::start_level(usize, tauri::ipc::channel::Channel<alloc::string::String>, tauri::state::State<alloc::sync::Arc<tokio::sync::mutex::Mutex<slot_machine_lib::AppState>, alloc::alloc::Global>>) -> slot_machine_lib::start_level::{async_fn_env#0};
199:    static fn slot_machine_lib::start_level::{async_fn#0}(*mut core::task::wake::Context) -> core::task::poll::Poll<core::result::Result<slot_machine_lib::models::StartResponse, alloc::string::String>>;

This function exists in slot_machine_lib, we can search for other functions in the same namespace using info functions slot_machine_lib::. In the massive list that was returned, we have the following functions that stand out:

  • slot_machine_lib::j9htd1
  • slot_machine_lib::AJBCKQOISJS::{closure#0}
  • slot_machine_lib::get_flag
  • slot_machine_lib::get_secret_flag

I opened up Ghidra and attempted to reverse the first two functions, but they were massive and I ended up giving up. Instead, I tried to see if I could call get_secret_flag from the front-end since I can see it being called by the other two functions at some point during initialization.

The function invoke was exported as i, let's try to find it being used in other files:

Bash
grep -ar 'i as.*from"../chunks/BEFjKjka\.js' ./dump/

A couple of files contain a call to invoke, but one stands out: /_app/immutable/nodes/4.D8UeKdei.js. It calls get_flag, so it must be used when a game is won to retrieve the flag and display it, exactly what we want to do. Just to be sure, we need to keep the file length the same as before our edit to not overwrite some important memory. Thankfully, there are some spaces in the file used for the CSS that we can delete to keep the same file size.

We can modify our gdb script to modify the returned value for that file to swap out get_flag for get_secret_flag:

Text Only
# Replace `get_flag` with `get_secret_flag` in `/_app/immutable/nodes/4.D8UeKdei.js`
break *tauri_utils::assets::EmbeddedAssets::get+113 if *((*(($rsp+0x18) as *mut u64)) as *mut u64) == 0xeaae0
commands
    set $content_start = *((*(($rsp+0x18) as *mut u64)+0x8) as *mut u64)
    restore ./patch_D8UeKdei.bin binary $content_start
end

# break on get_secret_flag to confirm the right function is called
break slot_machine_lib::get_secret_flag::{async_fn#0}

start

However, when we run this, we get an interesting error from the program, something about a panic:

Text Only
thread 'tokio-runtime-worker' panicked at src/lib.rs:281:5:
called `Result::unwrap()` on an `Err` value: Utf8Error { valid_up_to: 1, error_len: Some(1) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Let's rerun with the the environment variable set. It gives the following error:

Text Only
thread 'tokio-runtime-worker' panicked at src/lib.rs:281:5:
called `Result::unwrap()` on an `Err` value: Utf8Error { valid_up_to: 1, error_len: Some(1) }
stack backtrace:
   0: rust_begin_unwind
             at /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/std/src/panicking.rs:695:5
   1: core::panicking::panic_fmt
             at /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/core/src/panicking.rs:75:14
   2: core::result::unwrap_failed
             at /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/core/src/result.rs:1704:5
   3: core::result::Result<T,E>::unwrap
             at /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/core/src/result.rs:1109:23
   4: slot_machine_lib::get_secret_flag::{{closure}}
             at /tmp/slot-machine/src/lib.rs:281:5
   5: <futures_util::future::future::map::Map<Fut,F> as core::future::future::Future>::poll
             at /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.31/src/future/future/map.rs:55:37
   6: <futures_util::future::future::Map<Fut,F> as core::future::future::Future>::poll
             at /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.31/src/lib.rs:86:13
   7: slot_machine_lib::run::{{closure}}::{{closure}}::{{closure}}
             at /tmp/slot-machine/src/lib.rs:89:25
   8: tauri::ipc::InvokeResolver<R>::respond_async_serialized::{{closure}}
             at /root/.cargo/git/checkouts/tauri-69fbbe4d0942e697/72211be/crates/tauri/src/ipc/mod.rs:343:33
   9: tokio::runtime::task::core::Core<T,S>::poll::{{closure}}
             at /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.45.0/src/runtime/task/core.rs:331:17
  10: tokio::loom::std::unsafe_cell::UnsafeCell<T>::with_mut
             at /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.45.0/src/loom/std/unsafe_cell.rs:16:9
  11: tokio::runtime::task::core::Core<T,S>::poll
             at /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.45.0/src/runtime/task/core.rs:320:13
  12: tokio::runtime::task::harness::poll_future::{{closure}}
             at /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.45.0/src/runtime/task/harness.rs:532:19
  13: <core::panic::unwind_safe::AssertUnwindSafe<F> as core::ops::function::FnOnce<()>>::call_once
             at /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/core/src/panic/unwind_safe.rs:272:9
  14: std::panicking::try::do_call
             at /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/std/src/panicking.rs:587:40

[ ... snip ... ]

note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

It isn't very clear why it panicked, but it seems to be about some invalid UTF8 that was unwrap-ed in get_secret_flag. Let's investigate further and see where the crash happens:

Text Only
break slot_machine_lib::get_secret_flag::{{closure}}
start
continue

After winning the slots, our breakpoint is triggered. By running disas we can see that going instruction by instruction would take a while because the function itself is about 20000 instructions. I picked a location after the big blob of movaps/movups that seemed to be copying stuff around a lot and inserted a breakpoint. The address I picked was slot_machine_lib::get_secret_flag::{{closure}}+20911 because it was loading the address of a from_utf8 function. I had to use the address directly because the function name refused to resolve.

Text Only
gef➤  b *0x000000010066452f
Breakpoint 4 at 0x10066452f: file src/lib.rs, line 281.
gef➤  c
Continuing.

Thread 42 "tokio-runtime-w" hit Breakpoint 4, 0x000000010066452f in slot_machine_lib::get_secret_flag::{async_fn#0} () at src/lib.rs:281
281     in src/lib.rs
gef➤  context code
───────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
   0x100664523 <slot_machine_lib::get_secret_flag::{{closure}}+51a3> jmp    0x1006644e5 <_ZN16slot_machine_lib15get_secret_flag28_$u7b$$u7b$closure$u7d$$u7d$17h5505b858b551aa8bE+20837>
   0x100664525 <slot_machine_lib::get_secret_flag::{{closure}}+51a5> mov    rdx, QWORD PTR [rsp+0x20]
   0x10066452a <slot_machine_lib::get_secret_flag::{{closure}}+51aa> mov    rsi, QWORD PTR [rsp+0x28]
●→ 0x10066452f <slot_machine_lib::get_secret_flag::{{closure}}+51af> lea    rax, [rip+0xa0eada]        # 0x101073010 <_ZN4core3str8converts9from_utf817hd4f1e51ae1294830E>
   0x100664536 <slot_machine_lib::get_secret_flag::{{closure}}+51b6> lea    rdi, [rsp+0x2ae8]
   0x10066453e <slot_machine_lib::get_secret_flag::{{closure}}+51be> call   rax
   0x100664540 <slot_machine_lib::get_secret_flag::{{closure}}+51c0> jmp    0x100664542 <_ZN16slot_machine_lib15get_secret_flag28_$u7b$$u7b$closure$u7d$$u7d$17h5505b858b551aa8bE+20930>
   0x100664542 <slot_machine_lib::get_secret_flag::{{closure}}+51c2> cmp    QWORD PTR [rsp+0x2ae8], 0x0
   0x10066454b <slot_machine_lib::get_secret_flag::{{closure}}+51cb> je     0x1006645bd <_ZN16slot_machine_lib15get_secret_flag28_$u7b$$u7b$closure$u7d$$u7d$17h5505b858b551aa8bE+21053>
──────────────────────────────────────────────────────────────────────────────────────────────────
gef➤  si
0x0000000100664536      281     in src/lib.rs
gef➤
[Thread 0x7fff783f96c0 (LWP 51482) exited]
0x000000010066453e      281     in src/lib.rs
gef➤  so
Temporary breakpoint 5 at 0x100664540: file src/lib.rs, line 281.
gef➤  si
core::result::Result<&str, core::str::error::Utf8Error>::unwrap<&str, core::str::error::Utf8Error> (self=...) at /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/core/src/result.rs:1107
warning: 1107   /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/core/src/result.rs: No such file or directory
gef➤  context trace
─────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x100664542 → core::result::Result<&str, core::str::error::Utf8Error>::unwrap<&str, core::str::error::Utf8Error>(self=core::result::Result<&str, core::str::error::Utf8Error>::Err(core::str::error::Utf8Error {
    valid_up_to: 0x1,
    error_len: core::option::Option<u8>::Some(0x1)
  }))

We can see that we are now unwrapping an error, so it confirmed our hypothesis that a UTF8 decode failed earlier. This could mean that there is something that we miss that prevents us from successfully decrypting and decoding the flag.

This is as far as we got during the CTF on this challenge. However, after some discussions we had after the CTF, it seems that there was a sneaky anti-debugging feature that prevents successfully dumping the flag when a debugger is running.

Defeating the anti-debugging

I haven't been confronted with anti-debugging until now, so this is very new to me. I read a bit about it and here's what I found:

  1. a program can hunt for the byte 0xCC which indicate a software breakpoint (reference)
  2. a program can check /proc/pid/status for a TracerPID or attach ptrace to itself and do nothing (reference)

The first one seems impractical to implement since the check needs to call a function to verify, so let's attempt to see if the program is doing some ptrace magic.

My first idea is to launch the process, then read /proc/pid/status and see if there's a TracerPID. Using cat /proc/$(pidof slot-machine)/status | grep -r trace returns nothing.

Next, I tried looking for the symbol ptrace in the binary:

Text Only
gef➤  info functions ptrace
All functions matching regular expression "ptrace":

Non-debugging symbols:
0x00007fffef530a70  ptrace

That seems like good news, let's put a breakpoint on ptrace and continue the execution until we hit it:

Text Only
gef➤  b ptrace
Breakpoint 2 at 0x7fffef530a70
gef➤  c
Continuing.

[ ... snip ... ]

Thread 31 "tokio-runtime-w" hit Breakpoint 2, 0x00007fffef530a70 in ptrace ()
   from ./lib/libc.so.6

Alright, we do have a call to ptrace, let's see who called it:

Text Only
gef➤  context trace
─────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x7fffef530a70 → ptrace()
[#1] 0x1005f59c9 → slot_machine_lib::j9htd1::{async_fn#0}()
[#2] 0x10065cac2 → slot_machine_lib::gpio_subscribe::{async_fn#0}()

[ ... snip ... ]

Looks like we found the culprit. A function within gpio_subscribe seems to call ptrace. Let's add a breakpoint to our caller and inspect what value is returned:

Text Only
gef➤  b *0x1005f59c9
Breakpoint 3 at 0x1005f59c9: file src/lib.rs, line 130.
gef➤  c
Continuing.
[Thread 0x7fffe2ffe6c0 (LWP 53334) exited]

Thread 42 "tokio-runtime-w" hit Breakpoint 3, 0x00000001005f59c9 in slot_machine_lib::j9htd1::{async_fn#0} () at src/lib.rs:130
warning: 130    src/lib.rs: No such file or directory
gef➤  p $rax
$1 = 0xffffffffffffffff

According to its manpage, all errors return -1. We could probably patch ptrace to always return a static value, but we need to figure out what value it requires. I'm a bit lazy, so 0 looks like a pretty good success value to me, let's do that.

We can add this snippet to our .gdbinit to patch ptrace:

Text Only
break ptrace
commands
    set *($rip as *u8) = 0x48
    set *(($rip+1) as *u8) = 0x31
    set *(($rip+2) as *u8) = 0xC0
    set *(($rip+3) as *u8) = 0xC3
end

That should force ptrace to always return 0, which is not -1, so we should probably take a different path than before and hopefully that's enough to bypass the anti-debugging.

Running the app again, winning at the slots again, we successfully get the flag!

End notes

That's about it for now. This track was pretty interesting and made me learn a bit more about GDB, AppImages, Tauri and Rust binaries reversing with Ghidra.