Networking in C · intermediate · ~20 min

DNS resolution with getaddrinfo

- Call `getaddrinfo` to turn a hostname and service name into one or more ready-to-use socket addresses, for both IPv4 and IPv6. - Fill in a `struct addrinfo` *hints* block to control the family, socket type, and protocol you ask for. - Walk the returned linked list with `ai_next` and try `socket` + `connect` on each result until one succeeds. - Report resolver errors correctly with `gai_strerror`, not `errno`/`perror`. - Always release the result list with `freeaddrinfo`, and recognise why forgetting it leaks memory. - Apply a defensive check that rejects resolved addresses in internal ranges (loopback, RFC 1918, link-local) to blunt SSRF-style abuse.

Overview

Computers on a network find each other by IP address, but humans use names like example.com. DNS (the Domain Name System) is the global directory that maps names to addresses. Before your program can open a TCP connection to a named host, it must first translate that name into a numeric address it can hand to the socket layer. That translation step is name resolution.

In the Sockets introduction you connected using an address you already had. Real programs rarely have that luxury: a URL, a config file, or a user typed a name. getaddrinfo is the function that bridges the gap between "I have a name" and "I have a struct sockaddr I can pass to connect".

getaddrinfo is the modern, protocol-independent replacement for the old gethostbyname. In a single call it handles three jobs:

  • IPv4 lookups (A records → 32-bit addresses)
  • IPv6 lookups (AAAA records → 128-bit addresses)
  • Service-name-to-port translation — turning "https" into port 443, "http" into 80, and so on, by consulting /etc/services.

The big idea is that you describe what you want with a hints structure, and getaddrinfo hands back a linked list of candidate addresses. You try them in order. This design means the same code works unchanged whether the host has an IPv4 address, an IPv6 address, or both — which is exactly why it replaced the IPv4-only gethostbyname.

Throughout this lesson, keep one mental model: getaddrinfo produces untrusted data. What it returns depends on DNS, and DNS can be influenced by whoever controls the name being looked up. That is fine for hard-coded hosts, but the moment a hostname comes from user input or config, you must inspect the resolved address before you trust it.

Why it matters

Writing your own name resolver is a classic security trap. The DNS wire format, retries, IPv6 handling, and /etc/hosts interaction are all easy to get subtly wrong, and parsing attacker-influenced data by hand is exactly where buffer overflows and logic bugs breed. getaddrinfo is the standard, well-tested, OS-provided resolver — it is the only sensible choice for any code that takes a hostname from configuration or user input.

Using it correctly also future-proofs your program. Because it returns both IPv4 and IPv6 results from the same call, code written against getaddrinfo keeps working as networks migrate to IPv6, while old gethostbyname code silently fails to reach IPv6-only hosts.

Finally, the function sits on a security boundary. A server that resolves a user-supplied hostname and then connects to the result can be tricked into connecting to its own internal network — the SSRF attack. Knowing how resolution works is the first step to defending against it, which is why several of this lesson's exercises (getaddrinfo-allowlist, cidr-block-matcher, ipv4-parse-dotted) drill the address-checking math.

Core concepts

1. The addrinfo structure

getaddrinfo both consumes and produces struct addrinfo. The same type plays two roles: as hints (input describing what you want) and as results (output describing what was found).

struct addrinfo {
    int              ai_flags;      // AI_* flags (input behaviour)
    int              ai_family;     // AF_INET, AF_INET6, or AF_UNSPEC
    int              ai_socktype;   // SOCK_STREAM, SOCK_DGRAM
    int              ai_protocol;   // usually 0 (let it choose)
    socklen_t        ai_addrlen;    // length of ai_addr (result only)
    struct sockaddr *ai_addr;       // the actual address (result only)
    char            *ai_canonname;  // canonical name (result only)
    struct addrinfo *ai_next;       // next result in the list
};

When you build hints, you only set a few fields and zero the rest — a leftover garbage value in ai_addrlen or ai_next can confuse the call. The result entries have ai_addr and ai_addrlen filled in for you, ready to pass straight to connect.

Pitfall: declaring struct addrinfo hints; without zeroing it. Use struct addrinfo hints = {0}; or memset. Uninitialised hint fields are undefined behaviour waiting to happen.

Knowledge check: Why does the same struct type serve as both input and output? (Hint: think about which fields each role reads vs. writes.)

2. Hints — describing what you want

The hints struct narrows the search:

  • ai_family = AF_UNSPEC — accept IPv4 or IPv6 (the recommended default). AF_INET forces IPv4 only; AF_INET6 forces IPv6 only.
  • ai_socktype = SOCK_STREAM — TCP-style addresses. SOCK_DGRAM requests UDP-style.
  • ai_flags — behaviour toggles, OR'd together:
    • AI_PASSIVE — the result is for bind (a server). Without a node, it yields a wildcard address.
    • AI_NUMERICHOST — refuse DNS entirely; accept only an IP literal like 192.0.2.1. Great when you must not perform a network lookup.
    • AI_NUMERICSERV — the service must be a numeric port string, not a name.

When NOT to use AF_UNSPEC: if your code path genuinely only supports IPv4 (e.g. a legacy peer), pin AF_INET so you do not waste a connect attempt on an unreachable IPv6 address.

3. The result list

getaddrinfo returns a singly linked list of results via the res out-parameter. Each node is one candidate address. You walk it with ai_next and try each in turn:

res ──> addrinfo ──> addrinfo ──> addrinfo ──> NULL
          │             │             │
        ai_addr       ai_addr       ai_addr
        (IPv6)        (IPv4)        (IPv4)

For each node: socket() then connect().
First one that connects wins; close the rest's sockets.

The ordering is chosen by the system (RFC 6724 address selection), so the "best" address is usually first — but you must still be prepared for the first one to fail and fall through to the next.

Knowledge check (predict the output): If a host has one AAAA and two A records, and you set AF_UNSPEC, roughly how many nodes will the list contain, and what decides their order?

4. Error reporting — not the usual convention

Most C system calls return -1 and set errno. getaddrinfo does not. It returns 0 on success or a non-zero error code on failure, and that code is not an errno value. Format it with gai_strerror(rc):

rc = getaddrinfo(...)
rc == 0   → success, walk res
rc != 0   → failure, message = gai_strerror(rc)
            (do NOT call perror / read errno)

Common codes: EAI_NONAME (name does not resolve), EAI_AGAIN (temporary DNS failure, retry later), EAI_FAIL (permanent failure), EAI_MEMORY.

Pitfall: using perror("getaddrinfo"). It will print a misleading message based on an unrelated errno.

Knowledge check (find-the-bug): int rc = getaddrinfo(host, port, &hints, &res); if (rc < 0) { ... }. Why is the rc < 0 test wrong? (Hint: what range are the error codes in?)

5. SSRF — the resolver as an attack surface

SSRF (Server-Side Request Forgery) abuses a server that fetches a URL on the user's behalf. The attacker supplies a hostname they control — say evil.example configured to resolve to 127.0.0.1 or 169.254.169.254 (a cloud metadata endpoint). The server resolves it, sees nothing suspicious in the name, and connects to its own internal network.

Threat model (user-supplied hostname → server fetch)

  Attacker ──hostname──> [Public web service]
                              │ getaddrinfo()
                              ▼
                         resolves to 169.254.169.254  ← attacker-chosen
                              │ connect()
                              ▼
            ┌─────────── TRUST BOUNDARY ───────────┐
            │  Internal network / metadata service │  ← asset to protect
            └──────────────────────────────────────┘
  Entry point: the hostname field.  Asset: internal-only services.

Defence (defensive, lab-only mindset): after resolution, inspect the numeric address and reject anything in an internal range before you connect:

  • Loopback 127.0.0.0/8 and ::1
  • RFC 1918 private ranges 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • Link-local 169.254.0.0/16 and fe80::/10
  • Multicast and other reserved ranges

This is exactly the check the getaddrinfo-allowlist and cidr-block-matcher exercises build.

Knowledge check (explain in your own words): Why is validating the hostname string not enough on its own to stop SSRF? What must you also validate?

Syntax notes

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>     /* getaddrinfo, freeaddrinfo, gai_strerror, struct addrinfo */

/* node    : hostname or IP literal, or NULL (for servers with AI_PASSIVE)
 * service : port number as a string ("443") or a service name ("https"), or NULL
 * hints   : a partly-filled addrinfo describing the desired results
 * res     : OUT — receives a malloc'd linked list you must free                  */
int  getaddrinfo(const char *node, const char *service,
                 const struct addrinfo *hints, struct addrinfo **res);

void        freeaddrinfo(struct addrinfo *res);   /* releases the whole list */
const char *gai_strerror(int errcode);            /* human-readable error text */

Minimal correct skeleton:

struct addrinfo hints = {0};            /* zero ALL fields first */
hints.ai_family   = AF_UNSPEC;          /* IPv4 or IPv6 */
hints.ai_socktype = SOCK_STREAM;        /* TCP */

struct addrinfo *res = NULL;
int rc = getaddrinfo("example.com", "443", &hints, &res);
if (rc != 0) {                          /* non-zero == error (not -1) */
    fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
    return 1;
}
/* ... use res ... */
freeaddrinfo(res);                       /* always, on every path */

Lesson

getaddrinfo(3) is the modern, IPv4- and IPv6-clean replacement for the old gethostbyname.

It returns a linked list of addrinfo results. You walk that list, trying each address until one succeeds.

Defensive code goes one step further: it refuses any result that resolves to an internal address range. That refusal is the core defence against SSRF.

Code examples

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

/* Return 1 if this resolved address is in an internal/reserved range we must
 * refuse before connecting (a minimal SSRF guard). Covers the common IPv4
 * ranges plus IPv6 loopback and link-local. */
static int is_internal_addr(const struct sockaddr *sa) {
    if (sa->sa_family == AF_INET) {
        const struct sockaddr_in *s4 = (const struct sockaddr_in *)sa;
        /* ntohl: bytes arrive in network order; compare in host order. */
        unsigned long ip = ntohl(s4->sin_addr.s_addr);
        unsigned a = (ip >> 24) & 0xFF;
        unsigned b = (ip >> 16) & 0xFF;
        if (a == 127) return 1;                       /* 127.0.0.0/8 loopback   */
        if (a == 10)  return 1;                        /* 10.0.0.0/8  private     */
        if (a == 192 && b == 168) return 1;            /* 192.168.0.0/16 private  */
        if (a == 172 && b >= 16 && b <= 31) return 1;  /* 172.16.0.0/12  private  */
        if (a == 169 && b == 254) return 1;            /* 169.254.0.0/16 link-loc */
        return 0;
    }
    if (sa->sa_family == AF_INET6) {
        const struct sockaddr_in6 *s6 = (const struct sockaddr_in6 *)sa;
        if (IN6_IS_ADDR_LOOPBACK(&s6->sin6_addr))  return 1;  /* ::1     */
        if (IN6_IS_ADDR_LINKLOCAL(&s6->sin6_addr)) return 1;  /* fe80::/10 */
        return 0;
    }
    return 1; /* unknown family: refuse by default (fail closed) */
}

int main(int argc, char **argv) {
    const char *host = (argc > 1) ? argv[1] : "example.com";
    const char *port = (argc > 2) ? argv[2] : "443";

    struct addrinfo hints = {0};
    hints.ai_family   = AF_UNSPEC;     /* accept IPv4 or IPv6 */
    hints.ai_socktype = SOCK_STREAM;   /* TCP */

    struct addrinfo *res = NULL;
    int rc = getaddrinfo(host, port, &hints, &res);
    if (rc != 0) {                     /* non-zero is an error code, not -1 */
        fprintf(stderr, "getaddrinfo(%s): %s\n", host, gai_strerror(rc));
        return 1;
    }

    int sock = -1;
    for (struct addrinfo *p = res; p != NULL; p = p->ai_next) {
        char ipstr[INET6_ADDRSTRLEN] = "";
        void *addr;
        if (p->ai_family == AF_INET)
            addr = &((struct sockaddr_in  *)p->ai_addr)->sin_addr;
        else
            addr = &((struct sockaddr_in6 *)p->ai_addr)->sin6_addr;
        inet_ntop(p->ai_family, addr, ipstr, sizeof ipstr);

        if (is_internal_addr(p->ai_addr)) {
            fprintf(stderr, "refusing internal address: %s\n", ipstr);
            continue;                  /* SSRF guard: skip this candidate */
        }
        printf("trying %s ...\n", ipstr);

        sock = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
        if (sock == -1) continue;      /* try the next address */

        if (connect(sock, p->ai_addr, p->ai_addrlen) == 0) {
            printf("connected to %s\n", ipstr);
            break;                     /* first success wins */
        }
        close(sock);                   /* this one failed; clean up and retry */
        sock = -1;
    }

    freeaddrinfo(res);                 /* release the list on every path */

    if (sock == -1) {
        fprintf(stderr, "could not connect to %s:%s\n", host, port);
        return 1;
    }
    /* ... real work would send/recv here ... */
    close(sock);
    return 0;
}

What it does. It takes a host and port from the command line (defaulting to example.com:443), resolves them with getaddrinfo, and walks the result list. For each candidate it prints the numeric address, skips any internal/reserved address (the SSRF guard), and tries socket + connect. The first address that connects wins; the program then cleans up and exits.

Expected output (exact addresses vary by DNS and network; this is the shape, not a captured run):

trying 93.184.x.x ...
connected to 93.184.x.x

If you pass a host that resolves only to a loopback or private address, you will instead see refusing internal address: ... lines followed by could not connect.

Edge cases. A name with no records yields EAI_NONAME. A transient DNS outage yields EAI_AGAIN (worth a retry). A reachable host on a blocked port makes connect fail and the loop falls through to the next address or reports failure.

Line by line

We trace the key path for ./resolve example.com 443.

  1. host and port are set from argv, or defaults. These are stringsgetaddrinfo takes the port as text and parses it (or looks up a service name).
  2. struct addrinfo hints = {0}; zeroes every field. This matters: ai_flags, ai_protocol, and especially ai_next must start clean.
  3. hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; says "give me TCP addresses, IPv4 or IPv6."
  4. getaddrinfo(host, port, &hints, &res) performs the lookup. On success it allocates a linked list and points res at the head; it returns 0.
  5. The error check tests rc != 0 (not < 0). On failure, gai_strerror(rc) turns the code into readable text and we exit.
  6. The for loop walks res via p = p->ai_next. On each node:
    • inet_ntop converts the binary address in ai_addr to a printable string for logging.
    • is_internal_addr inspects the resolved numeric address. If it is loopback/private/link-local, we continue and never connect — this is the defensive heart of the program.
    • socket(...) creates a file descriptor matching the candidate's family/type/protocol. connect(...) attempts the TCP handshake using ai_addr/ai_addrlen exactly as supplied.
    • On success we break; on failure we close(sock) and let the loop try the next node.
  7. freeaddrinfo(res) runs regardless of whether we connected — the list was heap-allocated by the resolver and we own freeing it.

A small trace of the loop state for a host with two addresses (one internal, one public):

node  ai_addr      is_internal?  socket  connect   action
----  -----------  ------------  ------  --------  ----------------------
  1   127.0.0.1    yes           —       —         refuse, continue
  2   93.184.x.x   no            ok      success   print, break (sock>=0)

After the loop, sock != -1, so we skip the failure branch, do our work, and close(sock).

Common mistakes

1. Treating the return value like a normal syscall.

/* WRONG */
if (getaddrinfo(host, port, &hints, &res) < 0) {
    perror("getaddrinfo");      /* errno is unrelated here */
}

Why it is wrong: getaddrinfo returns a positive error code, never -1, and never sets errno for its own failures. The < 0 test misses every error, and perror prints garbage.

/* RIGHT */
int rc = getaddrinfo(host, port, &hints, &res);
if (rc != 0) {
    fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
    return 1;
}

Recognise it by the symptom: errors are silently ignored, or the message makes no sense ("Success", or a random unrelated error).

2. Forgetting freeaddrinfo.

/* WRONG: every lookup leaks the list */
if (rc == 0) { /* use res */ }
return 0;  /* res never freed */

Why it is wrong: the resolver heap-allocates the list. One leak is small; a server that resolves thousands of names per minute leaks steadily until it is killed. Fix: call freeaddrinfo(res) on every path after a successful call. Catch it with valgrind --leak-check=full.

3. Forgetting to zero the hints.

struct addrinfo hints;          /* WRONG: garbage fields */
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;

Why it is wrong: ai_flags, ai_protocol, etc. hold stack garbage and can change behaviour or trip undefined behaviour. Fix: struct addrinfo hints = {0}; (or memset(&hints, 0, sizeof hints);).

4. Closing the socket but reusing the loop variable. After connect succeeds and you break, do not also close(sock) inside the loop — you would close the very socket you wanted to keep. In the example, close only runs on the failure path, and break preserves the good fd.

5. Trusting the address because the name "looks safe." Validating the hostname string (no spaces, valid characters) does not stop SSRF, because the attacker controls what a valid name resolves to. You must inspect the resolved numeric address.

Debugging tips

Compile-time errors

  • implicit declaration of getaddrinfo → you forgot #include <netdb.h>.
  • unknown type name 'struct addrinfo' → same missing header, or missing <sys/socket.h>.
  • AF_UNSPEC/SOCK_STREAM undeclared → include <sys/socket.h>.

Runtime / logic errors

  • Lookups always fail with EAI_NONAME: check the name actually resolves, and that you did not accidentally set AI_NUMERICHOST (which rejects real hostnames).
  • EAI_AGAIN: a temporary DNS failure — not a bug in your code. Consider a bounded retry.
  • connect always fails even though resolution works: the address resolves but the port is closed or filtered. Confirm the service/port string is right ("443" vs "https").

Cross-check against the system resolver

getent ahosts example.com     # what the OS resolver returns (Linux)
dig +short example.com        # A records, concise
dig +short AAAA example.com    # AAAA (IPv6) records

If your program disagrees with these, the difference is usually a hints field (family or flags) or a typo in the name.

Questions to ask when it does not work

  1. Did I check rc != 0 and print gai_strerror(rc) so I can see why?
  2. Are my hints zeroed, with the family/socktype I actually want?
  3. Is the failure in resolution (getaddrinfo) or in the connection (connect)? Print at each stage to localise it.
  4. Am I freeing res only after I am done reading from it?

Memory safety

The result list is heap memory you do not allocate. getaddrinfo mallocs the list internally; you must release it with freeaddrinfo(res). Two failure modes:

  • Leak: never calling freeaddrinfo. Harmless once, dangerous in a loop or long-running server.
  • Use-after-free / dangling pointer: calling freeaddrinfo(res) and then dereferencing p->ai_addr. Every pointer into the list (including ai_addr, ai_canonname) becomes invalid the instant you free. Free last.

Address casting must match the family. ai_addr is a struct sockaddr *. Only cast it to struct sockaddr_in * when ai_family == AF_INET, and to struct sockaddr_in6 * when ai_family == AF_INET6. Casting the wrong way reads past the smaller structure — out-of-bounds access and undefined behaviour. The example checks ai_family before every cast.

Buffer sizing for printing. Use INET6_ADDRSTRLEN (large enough for any IPv6 text form) as the inet_ntop buffer size and pass sizeof buf. A buffer sized only for IPv4 (INET_ADDRSTRLEN) overflows on an IPv6 address.

Initialise before use. An un-zeroed hints struct is undefined behaviour. An un-initialised res that you then pass to freeaddrinfo after a failed call is also a bug — on failure the standard does not promise res is set, so do not free it unless rc == 0.

Robustness / validation. Because the resolved address is attacker-influenceable when the name comes from outside, treat is_internal_addr (or a stricter allow-list) as mandatory before connect, and fail closed for address families you do not recognise.

Real-world uses

Essentially every networked C program that connects by name goes through getaddrinfo. Real examples:

  • HTTP clients like curl and wget resolve the host from a URL before opening the socket.
  • TLS terminators and proxies (nginx, HAProxy upstreams) resolve backend hostnames.
  • Database and message-broker drivers resolve the server host from a connection string.
  • SSH and mail clients resolve their target hosts the same way.

Professional best-practice habits

Beginner rules:

  • Always check rc != 0 and log gai_strerror(rc).
  • Always freeaddrinfo on every path after a successful call.
  • Zero your hints; set only the fields you mean to.
  • Try every address in the list, not just the first.

Advanced habits:

  • Set a connect timeout (non-blocking connect + select/poll, or per-attempt deadline) so a slow address cannot hang the program.
  • For user-supplied hostnames, enforce a resolved-address allow/deny check (SSRF defence) and consider re-resolving consistently to avoid DNS-rebinding gaps.
  • Cache results respectfully (honour TTLs) only if you understand the staleness trade-offs; otherwise let the system resolver cache.
  • Prefer AF_UNSPEC for dual-stack reach; only pin a family when you have a concrete reason.

Practice tasks

Beginner 1 — Print all addresses.

  • Objective: resolve a host and list every address returned.
  • Requirements: take the hostname from argv[1] (default example.com), service "http". Use AF_UNSPEC, SOCK_STREAM. Walk the list and print each address with inet_ntop. Call freeaddrinfo.
  • Example: ./list example.com → one line per resolved address.
  • Hints: branch on ai_family to pick sin_addr vs sin6_addr. Concepts: result list, inet_ntop.

Beginner 2 — Correct error reporting.

  • Objective: prove you handle the error convention.
  • Requirements: resolve a deliberately bad name (e.g. "no.such.host.invalid"). Print the result of gai_strerror(rc). Do not call perror. Return a non-zero exit code on failure.
  • Expected: a message such as Name or service not known.
  • Hints: test rc != 0, not rc < 0. Concepts: error codes, gai_strerror.

Intermediate 1 — Connect with fallback.

  • Objective: connect to a host by trying each address until one works.
  • Requirements: for each list node, socket then connect; on failure close and continue; on success report the address and stop. Always freeaddrinfo.
  • Constraints: do not leak sockets; close every failed attempt.
  • Hints: keep a sock = -1 sentinel; break on success. Concepts: result list traversal, socket lifecycle.

Intermediate 2 — Reject internal addresses.

  • Objective: add an SSRF guard.
  • Requirements: before connecting, skip any address in 127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12, or 169.254.0.0/16; log a refusal line. Cover IPv6 loopback/link-local too.
  • Example: resolving a name pointed at 127.0.0.1 prints refusing internal address: 127.0.0.1 and never connects.
  • Hints: use ntohl and bit-shifts to extract octets; fail closed on unknown families. Concepts: byte order, CIDR checks, defensive validation. (Relates to getaddrinfo-allowlist, cidr-block-matcher.)

Challenge — Connect with a timeout.

  • Objective: never block forever on a slow address.
  • Requirements: put the socket in non-blocking mode, call connect, and use select/poll with a 3-second deadline per address; if it times out, close and move to the next. Combine with the SSRF guard from the previous task.
  • Constraints: handle EINPROGRESS; restore blocking mode (or keep non-blocking deliberately); clean up every socket.
  • Hints: after select reports writable, check SO_ERROR with getsockopt to confirm the connect actually succeeded. Concepts: non-blocking I/O, select/poll, robust resource cleanup.

Summary

  • DNS maps names to addresses; getaddrinfo is the standard, IPv4/IPv6-clean way to do that translation in C, replacing the old gethostbyname.
  • You describe what you want with a zeroed hints struct (ai_family = AF_UNSPEC, ai_socktype = SOCK_STREAM) and receive a linked list of results; walk it with ai_next and try socket + connect on each until one succeeds.
  • Error convention is different: it returns 0 or a positive error code (not -1), so test rc != 0 and format with gai_strerror(rc) — never perror.
  • Always freeaddrinfo(res) after a successful call, and free it last so no pointer into the list dangles. Cast ai_addr only after checking ai_family.
  • Because resolved addresses are attacker-influenceable for user-supplied names, reject internal/reserved ranges before connecting to defend against SSRF — and fail closed on anything you do not recognise.
  • Common mistakes: rc < 0 tests, missing freeaddrinfo, un-zeroed hints, wrong struct casts, and trusting a name without checking where it resolves.

Practice with these exercises