There isn't really a way to do this nicely. Modern Linux parted ways with read-implies-exec years ago in v5.8 (see patch here). Without touching the binary (either its ELF headers or its code) there isn't a general way to restore the old behavior. Certain tricks may still work on e.g. 32-bit binaries, old CPUs or weird architectures but that's about it. An example is x86 32-bit ELFs lacking explicit stack protection flags, which will still have read-implies-exec today (see table here), though modern compilers usually shouldn't omit stack protection flags by default.
I have been teaching and writing challenges for cybersecurity courses focused on binary exploitations at different levels for a while, and this change definitely warranted an update to the whole teaching setup for entry level binary exploitation. Nowadays, it is not realistic to go with the good ol' "everything is executable" assumption that would permit to jump to shellcode anywhere. Of course, there are still situations where read-implies-exec or full RWX/unprotected memory are a thing, like embedded environments, but in general this cannot be assumed anymore. Exploitation (thankfully) got harder.
Ok, but what if I really want it?
At the end of the day, anything is possible if you have root privileges and the ability to load custom kernel modules. After all, it isn't unthinkable to want to play around like in the old days on a toy VM (I would definitely not recommend doing this on your actual machine).
If you want to have fun, here's a kernel module that I just wrote for x86-64 that re-enables read-implies-exec via kprobes for the whole system when inserted (you need a kernel with the default CONFIG_KPROBES=y for this to work):
// SPDX-License-Identifier: (GPL-2.0 OR MIT)
/**
* Restore old read-implies-exec kernel behavior via a kprobes hack: hook into
* setup_new_exec() to set the READ_IMPLIES_EXEC personality flag for the
* current task and into setup_arg_pages() to force executable_stack=EXSTACK_ENABLE_X.
*/
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/binfmts.h>
#ifndef CONFIG_X86_64
#error "This module only supports x86-64"
#endif
#ifdef pr_fmt
#undef pr_fmt
#endif
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
static int kp_setup_new_exec_pre(struct kprobe *kp, struct pt_regs *regs)
{
current->personality |= READ_IMPLIES_EXEC;
return 0;
}
static int kp_setup_arg_pages_pre(struct kprobe *kp, struct pt_regs *regs)
{
regs->dx = EXSTACK_ENABLE_X;
return 0;
}
static struct kprobe kps[] = {
{ .pre_handler = kp_setup_arg_pages_pre, .symbol_name = "setup_arg_pages" },
{ .pre_handler = kp_setup_new_exec_pre, .symbol_name = "setup_new_exec" },
};
static int __init modinit(void)
{
int ret;
for (unsigned i = 0; i < ARRAY_SIZE(kps); i++) {
ret = register_kprobe(&kps[i]);
if (ret < 0) {
pr_err("Failed to register kprobe for %s: %d\n",
kps[i].symbol_name, ret);
return -1;
}
pr_info("Registered kprobe for %s\n", kps[i].symbol_name);
}
pr_warn("Your system now runs with old read-implies-exec semantics!\n");
return 0;
}
static void __exit modexit(void)
{
for (unsigned i = 0; i < ARRAY_SIZE(kps); i++) {
unregister_kprobe(&kps[i]);
pr_info("Unregistered kprobe for %s\n", kps[i].symbol_name);
}
}
module_init(modinit);
module_exit(modexit);
MODULE_VERSION("0.1");
MODULE_DESCRIPTION("Restore old read-implies-exec behavior via a kprobes hack");
MODULE_AUTHOR("Marco Bonelli");
MODULE_LICENSE("Dual MIT/GPL");
Test on Linux v6.12 on a x86-64 QEMU VM with busybox:
/ # cat /proc/self/maps
00400000-00401000 r--p 00000000 00:02 6 /bin/busybox
00401000-005bc000 r-xp 00001000 00:02 6 /bin/busybox
005bc000-0067d000 r--p 001bc000 00:02 6 /bin/busybox
0067e000-00688000 rw-p 0027d000 00:02 6 /bin/busybox
00688000-0068b000 rw-p 00000000 00:00 0
30b03000-30b26000 rw-p 00000000 00:00 0 [heap]
7f845aeed000-7f845aef1000 r--p 00000000 00:00 0 [vvar]
7f845aef1000-7f845aef3000 r-xp 00000000 00:00 0 [vdso]
7fffe91a8000-7fffe91c9000 rw-p 00000000 00:00 0 [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
/ # insmod read_implies_exec.ko
[ 8.195897] read_implies_exec: loading out-of-tree module taints kernel.
[ 8.200370] read_implies_exec: Registered kprobe for setup_arg_pages
[ 8.201108] read_implies_exec: Registered kprobe for setup_new_exec
[ 8.201497] read_implies_exec: Your system now runs with old read-implies-exec semantics!READ_IMPLIES_EXEC personality.
/ # cat /proc/self/maps
00400000-0067d000 r-xp 00000000 00:02 6 /bin/busybox
0067e000-00688000 rwxp 0027d000 00:02 6 /bin/busybox
00688000-0068b000 rwxp 00000000 00:00 0
26519000-2653c000 rwxp 00000000 00:00 0 [heap]
7f75b7d91000-7f75b7d95000 r--p 00000000 00:00 0 [vvar]
7f75b7d95000-7f75b7d97000 r-xp 00000000 00:00 0 [vdso]
7fff29ba3000-7fff29bc4000 rwxp 00000000 00:00 0 [stack]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
READ_IMPLIES_EXECdoes make the heap executable, but that it's not actually inherited acrossexecfromsetarch -X. Have you considered running your binary with a kludged loader, or using LD_PRELOAD to inject code?setarch $(uname -m) --read-implies-exec <my binary and args>the old behavior is restored. But unfortunately, you are also right that there are problems with the personality not inheriting properly at times. If only I'd be able to fix this issue in the case of GDB and its debugged program launch... you can get the binary to run with the personality by runninggdb setarch -ex "set args x86_64 -X <my binary>, but then the rest of the debugging is understandably not working. I haven't thought about the other options, however they wouldn't be the best choices in my case.