Tags: risc-v rev
Rating:
# Exposed (rev)
Writeup by: [xlr8or](https://ctftime.org/team/235001)
As part of this challenge we get a stripped ELF binary compiled for the RISC-V architecture.
Although ghidra deals with this architecture pretty well, there are some aspects for which we need to consult the assembly, so let's take a quick look at the most important parts of the architecture:
* functions are called similarly to ARM, so it is possible that no return address is on the stack, but only the link register (`ra`) is set.
* `ecall` is used to invoke syscalls, syscall number goes to `a7`, arguments start from `a0` and return value also goes to `a0`
* registers with `s*` are preserved across function calls
[source](https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf)
Now that we know more we are ready to dive in. We can get to `main` by following the function that is called from `entry`
In main we see that some function is called in a loop:
```c
long lVar1;
undefined local_18 [4];
undefined4 local_14;
local_18[0] = 0;
local_14 = 0xffffffff;
do {
lVar1 = FUN_0001058c(local_18);
} while (lVar1 == 0);
```
Since there is nowhere else to go let's see that function. This function is pretty large, I bet we will find the flag in here.
Let's take a look at the first function that is called from here:
```c
long lVar1;
int iVar2;
int local_14;
local_14 = 0;
while( true ) {
if (size <= (ulong)(long)local_14) {
return 0;
}
lVar1 = read_wrapper(0,(void *)((long)buf + (long)local_14),size - (long)local_14);
iVar2 = (int)lVar1;
if (iVar2 < 0) break;
local_14 = iVar2 + local_14;
}
return (long)iVar2;
```
I have already name the function that is called from here `read_wrapper`. But we can see that the function is called with 2 arguments, then the return value is checked to be negative, otherwise we increment some counter, which is compared against the second argument.
Therefore we deduce that the second argument is an amount, and the first argument must be a pointer where we are going to put the data.
Let's look at `read_wrapper`
The decompiled result, even with renaming is not too informative:
```c
long read_wrapper(int fd,void *buf,ulong length)
{
/* sys_read */
ecall();
return (long)fd;
}
```
This is where we rather need to look at the assembly:
```asm
c.addi16sp sp,-0x30
c.sdsp s0,0x28(sp)
c.addi4spn s0,sp,0x30
c.mv a5,fd
sd buf,-0x20=>local_20(s0)
sd length,-0x28=>local_28(s0)
sw a5,-0x14=>local_14(s0)
lw a5,-0x14=>local_14(s0)
c.mv fd,a5
ld a5,-0x20=>local_20(s0)
c.mv buf,a5
ld a5,-0x28=>local_28(s0)
c.mv length,a5
li a7,0x3f
ecall
c.mv a5,fd
c.addiw a5,0x0
c.mv fd,a5
c.ldsp s0,0x28(sp)
c.addi16sp sp,0x30
ret
```
We see the `ecall` instruction meaning, that a syscall is being made here. `a7 = 0x3f`, we can use this [amazing risc syscall table](https://jborza.com/post/2021-05-11-riscv-linux-syscalls/) to figure out what syscall is being made.
According to the table `read` is called, and this is where I knew from to give the function the name that it has.
Now let's go back to the large function, and inspect the first few lines:
```c
iVar2 = read_input(local_120,6);
if (iVar2 != 0) {
return (long)iVar2;
}
if ((0x100 < (ushort)local_120._4_2_) || ((ushort)local_120._4_2_ < 6)) {
write_some_value(1);
return -1;
}
iVar2 = read_input(auStack_11a,(ulong)(ushort)local_120._4_2_ - 6);
```
1. We read 6 bytes from stdin, the read needs to succeed
2. The last 2 bytes need to be between 6 and 256 (inclusive)
3. We read more bytes, exactly the amount the last 2 bytes indicate - 6
From this we can deduce that `packet[4:6]` should contain the size of the message we are sending, and it is at least 6, because the header of the message is 6 bytes.
Now let's look at the next few lines:
```c
if (iVar2 != 0) {
return (long)iVar2;
}
uVar3 = calc_checksum(local_120);
if (uVar3 != (ushort)local_120._0_2_) {
write_some_value(1);
return -1;
}
```
1. The read for the full message needs to succeed
2. We calculate the message checksum (again a function I have already named, but it will be explained how I arrived at that name, when the function was not named)
3. The checksum we calculate needs to be the same as `packet[0:2]`
```c
short calc_checksum(char *buf)
{
int local_18;
short local_12;
local_12 = 1;
for (local_18 = 2; local_18 < (int)(uint)*(ushort *)(buf + 4); local_18 = local_18 + 1) {
local_12 = local_12 * ((byte)buf[local_18] + 0x539);
}
return local_12;
}
```
So we see that this function takes the full message buffer, skips the first 2 bytes, and goes through the entire message (remember `packet[4:2]` or `*(ushort *)(buf + 4)` is the length of the entire message).
While going through the message we calculate some value, influenced by constants and the current byte of the message.
From this I have made the guess that this must be some sort of checksum for the whole message. And now we also know how to produce the first 2 bytes of the header.
Let's further analyse the big function:
```c
if (local_120[3] != '\x01') {
write_some_value(3);
return -1;
}
uVar1 = CONCAT11(*param_1,local_120[2]);
```
This just tells us that `packet[3] == 1` should hold.
Next we construct `uVar1` based on `packet[2]` and some value that is passed as an argument to the function.
After this there are a bunch of conditionals checking `uVar1` against constant values and doing different things. From this we deduce that `packet[2]` combined with the first parameter will determine what function the server is going to execute.
Since the conditionals are a bit verbose I am not going to paste the whole section, but let's look at what operations the server accepts:
* `0x2fe`
* `0x1fe`
* `0x240`
* `0x130`
* `0xfe`
* `0x10`
First let's note that we need to somehow influence the value of the first argument to this function. This is because `packet[2]` is only one byte, however some operations are invoked by 2 bytes.
All instructions with `fe` at the end do the same function, regardless of the value of `param_1`:
```c
*(undefined4 *)(param_1 + 4) = 0xffffffff;
*param_1 = 0;
lVar4 = 0;
```
If we compare this with `main` the `param_1` array will get the same values, as they had when `main` set them, therefore I have named these operations *param_1 reset*
`0x10`:
```c
*param_1 = 1;
lVar4 = FUN_0001038a();
return lVar4;
```
This is definitely important, since this is the only other operation we can invoke with a 1 byte op code, and it sets the value of `param_1`.
The function called there, will just make the server send a reply to us. I won't explain it more in details, it is left as an exercise to the reader :)
The next interesting op code is `0x130`:
```c
if (local_120._4_2_ != 0x26) {
write_some_value(1);
return -1;
}
/* leaves open fd in param_1+4 */
lVar4 = cmd_open_file(param_1,local_120);
return lVar4;
```
First we see that in order to execute this operation the length of the full message should be `0x26`.
Next a function is called, let's look into it in details:
```c
undefined8 cmd_open_file(undefined *param_1,char *msg_buf)
{
undefined4 flag_fd;
long lVar1;
undefined8 uVar2;
undefined2 local_20;
undefined local_1e;
undefined local_1d;
undefined2 local_1c;
undefined2 local_1a;
undefined2 local_18;
msg_buf[0x25] = '\0';
lVar1 = string_cmp("flag.txt",msg_buf + 6);
if (lVar1 == 0) {
flag_fd = openat_wrapper(msg_buf + 6,0,0);
*(undefined4 *)(param_1 + 4) = flag_fd;
if (*(int *)(param_1 + 4) < 0) {
write_some_value(0xff);
uVar2 = 0xffffffffffffffff;
}
else {
*param_1 = 2;
local_20 = 0;
local_1e = 0x31;
local_1d = 1;
local_1c = 10;
local_1a = (undefined2)*(undefined4 *)(param_1 + 4);
local_18 = (undefined2)((uint)*(undefined4 *)(param_1 + 4) >> 0x10);
local_20 = calc_checksum((char *)&local_20);
uVar2 = write_output(&local_20,10);
}
}
else {
write_some_value(2);
uVar2 = 0xffffffffffffffff;
}
return uVar2;
}
```
1. The message should contain `flag.txt` after the header
2. The file descriptor is stored in `param_1 + 4`
3. `flag.txt` is opened
4. The value of `param_1` is upgraded to become 2, so next we can look at opcodes starting with 2
5. The file descriptor is sent back by the server to us (`local_1a` and `local_18`)
To figure out what these functions do, I have used the same methodology discussed for the `read_wrapper`, just look at the function and inspect the syscalls it makes.
`0x240`:
```
if (local_120._4_2_ != 10) {
write_some_value(1);
return -1;
}
lVar4 = cmd_output_fd(param_1,local_120);
return lVar4;
```
We see the message length should be 10 (the header and 4 bytes for the integer of the file descriptor). A function is called, passing the message to it.
```c
undefined8 cmd_output_fd(long param_1,long param_2)
{
undefined2 uVar1;
long lVar2;
undefined8 uVar3;
undefined8 local_20;
undefined4 local_18;
undefined2 local_14;
if (*(int *)(param_2 + 6) == *(int *)(param_1 + 4)) {
local_20 = 0xe01410000;
local_18 = 0;
local_14 = 0;
lVar2 = read_wrapper(*(int *)(param_2 + 6),(void *)((long)&local_20 + 6),8);
if (lVar2 < 0) {
write_some_value(0xff);
uVar3 = 0xffffffffffffffff;
}
else {
uVar1 = calc_checksum((char *)&local_20);
local_20 = CONCAT62(local_20._2_6_,uVar1);
uVar3 = write_output(&local_20,0xe);
}
}
else {
write_some_value(2);
uVar3 = 0xffffffffffffffff;
}
return uVar3;
}
```
1. It is checked if the value of the message we have sent is the same as the stored file descriptor from the previous operation (`0x130`)
2. It reads 8 bytes from the given file descriptor
3. It sends us the bytes that were just read
Know we know everything there is to know about this server. The plan of attack is as follows:
1. Send `0x10` to upgrade the first parameter
2. Send `0x30` with `flag.txt` to open the `flag.txt` file and save the file descriptor the server sends to us
3. Send `0x40` with the file descriptor we have saved from the previous request until we read all bytes from the flag.
The python script below will achieve this:
```python
from pwn import *
import ctypes
import sys
def calc_checksum(msg):
v = 1
for i in range(2, u16(msg[4:6])):
v *= (msg[i] + 1337)
return ctypes.c_ushort(v).value
def construct_message(typ, content):
msg = b'\x00' * 2 + typ + b'\x01' + p16(6 + len(content)) + content
chks = calc_checksum(msg)
return p16(chks) + msg[2:]
p = remote('rumble.host', 4096)
upgrade = construct_message(b'\x10', b'')
open_file = construct_message(b'\x30', b'flag.txt'.ljust(0x20, b'\x00'))
p.send(upgrade)
reply = p.recv(6)
print('>', reply)
p.send(open_file)
reply = p.recv(10)
print('>', reply)
remote_fd = reply[6:]
read_file = construct_message(b'\x40', remote_fd)
while True:
p.send(read_file)
data = p.recv(0xe)
print(data[6:])
input('.')
p.interactive()
```