In this write-up, I will analyze the "The Many Quirks of Linux libc" challenge from the Trail of Bits C/C++ Testing Handbook. The challenge consists of a C program that performs several validation steps on a user input (IP address) before executing a system-level ping command.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>

#define ALLOWED_IP "127.3.3.1"

int main() {
    char ip_addr[128];
    struct in_addr to_ping_host, trusted_host;

    // get address
    if (!fgets(ip_addr, sizeof(ip_addr), stdin))
        return 1;
    ip_addr[strcspn(ip_addr, "\n")] = 0;

    // verify address
18  if (!inet_aton(ip_addr, &to_ping_host))
        return 1;
20  char *ip_addr_resolved = inet_ntoa(to_ping_host);

    // prevent SSRF
23  if ((ntohl(to_ping_host.s_addr) >> 24) == 127)
        return 1;

    // only allowed
    if (!inet_aton(ALLOWED_IP, &trusted_host))
        return 1;
29  char *trusted_resolved = inet_ntoa(trusted_host);

31  if (strcmp(ip_addr_resolved, trusted_resolved) != 0)
        return 1;

    // ping
    char cmd[256];
36  snprintf(cmd, sizeof(cmd), "ping '%s'", ip_addr);
37  system(cmd);
    return 0;
}

Initially, the code looks to suffer from a classic command injection vulnerability (lines 36&37). However, as we will see, the program is safe against code injection but contains two libc "gotchas".

The intended logic of the program can be summarized as follows:

1. Read an IP from stdin

2. Parse it using inet_aton()

3. Convert it back to a string with inet_ntoa()

4. Block loopback addresses

5. Compare against a trusted IP

6. If everything matches, run ping

Before reaching the system() call, the input should pass several checks. The first check verifying the input is a valid IP address using function inet_aton(). This libc function is used to convert an IPv4 address into a binary form. The second check attempts to ensure that the IP address is not from a restricted range(SSRF protection) and compares it against an allowed host. This is done using the function inet_ntoa().

So if we just run the code and try to insert an input like 127.0.0.1 or even the trusted host(127.3.3.1), the program will ignore them all! it means something is not working as intended and we should investigate!

None

The first issue: inet_ntoa() is not what it looks like

The first obvious problem in this code comes from how the function inet_ntoa() is being used twice to convert two different struct in_addr values into string form. This function converts a binary format IPv4 address into a dotted format, writes the result into a static memory location, and returns a pointer to that location.

The first call to this function happens at line 20 where it converts the to_ping_host from binary format to string:

char *ip_addr_resolved = inet_ntoa(to_ping_host);

And the second call happens at line 29 where it converts the trusted_host into a string(127.3.3.1):

char *trusted_resolved = inet_ntoa(trusted_host);

After this second call, the memory location pointed by *ip_addr_resolved is being overwritten to store the new value which is being pointed by *trusted_resolved. So regardless of whatever IP address is being inserted as input, both pointers always point to the same location. And the value of the location will always be the newly written value. As a result, this will directly affect the comparison at line 31:

if (strcmp(ip_addr_resolved, trusted_resolved) != 0)

Since always both values are the same, the result of strcmp is 0 and that is why this check always fails.

This can be investigated during the debugging with GDB and having a random user input like 8.8.8.8:

None

As you can see, both pointers are pointing to the same location with the same value. This bug feels like a TOCTOU style vulnerability, where a value is checked but later overwritten before the usage.

The second issue: IPv4 parsing is way too flexible

The second issue relates to the behavior of function inet_aton().

In line 23, the code attempts to prevent SSRF attacks by blocking loopback addresses beginning with 127:

if ((ntohl(to_ping_host.s_addr) >> 24) == 127)

The logic behind filtering the loopback ranges works fine. For example, if we try to ping localhost IP, the program will correctly reject it (as we saw in the first screenshot).

However, at line 18, the code relies on inet_aton() function to validate whether the user input is a valid IP address:

if (!inet_aton(ip_addr, &to_ping_host))

The problem is, this function is not a reliable verifier and is infamous for many weird behaviours including supporting abbreviated and non-standard IP formats. So one bypass that I have found to work with this case is to simply use 0 or 0.0.0.0 as input:

None

Simply because 0.0.0.0 does not start with 127 and the system resolver interprets 0.0.0.0 as a reference to the local host, the loopback check passes.

Why there is no command injection?

Although the program eventually executes a shell command using system(), direct command injection is not possible in this case. Let's have a look at how the command string is constructed:

snprintf(cmd, sizeof(cmd), "ping '%s'", ip_addr);

And the command injection attempt fails:

None

From GDB point of view:

None

Because the user input is wrapped inside single quotes, shell meta characters such as ;, |, or && cannot break out of the argument.

To make it vulnerable, we can simply change the code to:

snprintf(cmd, sizeof(cmd), "ping %s", ip_addr);

Now let's inject some command:

None

GDB view:

None

Conclusion

This challenge demonstrates two classic issue in Linux libc usage:

inet_ntoa() uses a static buffer, causing multiple calls to overwrite previous values.

inet_aton() accepts flexible IPv4 formats, making it unreliable for strict input validation.

Related links:

C/C++ Testing Handbook Challenge

Trail of Bits Testing Handbook