ByPassing Read-Only Memory and Why mseal() is needed
May 19, 2026
As one may be familiar, the MMU (Memory Management Unit) enforces and supports memory protection such as marking a page to be READ-ONLY (RO). But it turns out there is an API mprotect()
that allows us to change the access protection of the calling process’s memory pages. Thus mprotect() could be used to mark pages marked for RO as writable or executable, an undesirable behavior for
any critical program as attackers could utilise this weakness to write custom payloads to either gain rootshell or bypass local checks.
To prevent tampering of permission flags of the process’s pages by sealing it, both the Linux Kernel and glibc introduced mseal()
in the past year or two.
In an ELF program, the read-only section (.rodata) should not be writtable and we can confirm this via objdump that was indeed the intention with the existence of READONLY label:
$ objdump -h ./rodata_write | grep -A1 .rodata
$ objdump -h ./rodata_write | grep -A1 .rodata
./rodata_write: format de fichier elf64-x86-64
--
13 .rodata 00000088 0000000000401358 0000000000401358 00001358 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
Thus, when the program is executed, we should be expecting something like this in their mappings (see /proc/<pid>/maps)
00401000-00402000 r--p 00001000 00:2d 26970 /tmp/rodata_write
where this region of memory is marked as read-only as expected (i.e. notice how it r--p and not rw-p for instance in this region).
However, if I invoke mprotect() which allows us to change protection of the calling process’s memory map, we can in fact modify the rodata.
If I define a constant string “Hello”, we can see that it is stored in rodata section:
$ objdump -s -j .rodata ./rodata_write | grep -B2 Hello
Contenu de la section .rodata :
401358 01000200 00000000 00000000 00000000 ................
401368 48656c6c 6f000000 55736167 653a2025 Hello...Usage: %
Normally I would not be able to overwrite this section of memory as it does not have write permission but thanks to mprotect(), I could:
mprotect((void *)page_start, len, PROT_READ | PROT_WRITE)
As seen below:
$ ./rodata_write Bye
Before: Hello
After: Bye
And if we were to check the mapping again, we’ll now see the same region of memory is now marked as write (w):
00401000-00402000 rw-p 00001000 00:2d 26970 /tmp/rodata_write
But if I comment out the line containing mprotect(), we eventually will hit a segfault when trying to overwrite the const string residing in the .rodata section with “Bye”:
$ ./rodata_write Bye
Before: Hello
Erreur de segmentation (core dumped)./rodata_write Bye
Hopefully this was a small enough example to illustrate what mseal() can be used for.
Resources:
Out of laziness, the code below is mainly generated by Claude but one can easily replicate this by following along
trailofbits example of using mprotect() to obtain rootshell.
#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
static const char message[] = "Hello";
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <replacement string>\n", argv[0]);
return 1;
}
const char *replacement = argv[1];
size_t repl_len = strlen(replacement);
size_t msg_len = sizeof(message) - 1; // exclude null terminator
if (repl_len > msg_len) {
fprintf(stderr, "Replacement too long: max %zu chars (got %zu)\n",
msg_len, repl_len);
return 1;
}
long page_size = sysconf(_SC_PAGESIZE);
uintptr_t addr = (uintptr_t)message;
uintptr_t page_start = addr & ~(page_size - 1);
size_t len = (addr - page_start) + sizeof(message);
printf("Before: %s\n", message);
printf("PID: %d\n", getpid());
getchar();
if (mprotect((void *)page_start, len, PROT_READ | PROT_WRITE) != 0) {
perror("mprotect");
return 1;
}
// Zero out the old string first, then write replacement
memset((void *)message, 0, msg_len);
memcpy((void *)message, replacement, repl_len);
printf("After: %s\n", message);
getchar();
mprotect((void *)page_start, len, PROT_READ);
return 0;
}