Networking in C · intermediate · ~20 min
- 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.
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:
"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.
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.
addrinfo structuregetaddrinfo 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.)
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.
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?
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?)
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:
127.0.0.0/8 and ::110.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16169.254.0.0/16 and fe80::/10This 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?
#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 */
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.
#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.
We trace the key path for ./resolve example.com 443.
host and port are set from argv, or defaults. These are strings — getaddrinfo takes the port as text and parses it (or looks up a service name).struct addrinfo hints = {0}; zeroes every field. This matters: ai_flags, ai_protocol, and especially ai_next must start clean.hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; says "give me TCP addresses, IPv4 or IPv6."getaddrinfo(host, port, &hints, &res) performs the lookup. On success it allocates a linked list and points res at the head; it returns 0.rc != 0 (not < 0). On failure, gai_strerror(rc) turns the code into readable text and we exit.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.break; on failure we close(sock) and let the loop try the next node.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).
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.
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
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
rc != 0 and print gai_strerror(rc) so I can see why?getaddrinfo) or in the connection (connect)? Print at each stage to localise it.res only after I am done reading from it?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:
freeaddrinfo. Harmless once, dangerous in a loop or long-running server.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.
Essentially every networked C program that connects by name goes through getaddrinfo. Real examples:
curl and wget resolve the host from a URL before opening the socket.Professional best-practice habits
Beginner rules:
rc != 0 and log gai_strerror(rc).freeaddrinfo on every path after a successful call.Advanced habits:
select/poll, or per-attempt deadline) so a slow address cannot hang the program.AF_UNSPEC for dual-stack reach; only pin a family when you have a concrete reason.Beginner 1 — Print all addresses.
argv[1] (default example.com), service "http". Use AF_UNSPEC, SOCK_STREAM. Walk the list and print each address with inet_ntop. Call freeaddrinfo../list example.com → one line per resolved address.ai_family to pick sin_addr vs sin6_addr. Concepts: result list, inet_ntop.Beginner 2 — Correct error reporting.
"no.such.host.invalid"). Print the result of gai_strerror(rc). Do not call perror. Return a non-zero exit code on failure.Name or service not known.rc != 0, not rc < 0. Concepts: error codes, gai_strerror.Intermediate 1 — Connect with fallback.
socket then connect; on failure close and continue; on success report the address and stop. Always freeaddrinfo.sock = -1 sentinel; break on success. Concepts: result list traversal, socket lifecycle.Intermediate 2 — Reject internal addresses.
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.127.0.0.1 prints refusing internal address: 127.0.0.1 and never connects.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.
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.EINPROGRESS; restore blocking mode (or keep non-blocking deliberately); clean up every socket.select reports writable, check SO_ERROR with getsockopt to confirm the connect actually succeeded. Concepts: non-blocking I/O, select/poll, robust resource cleanup.getaddrinfo is the standard, IPv4/IPv6-clean way to do that translation in C, replacing the old gethostbyname.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.0 or a positive error code (not -1), so test rc != 0 and format with gai_strerror(rc) — never perror.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.rc < 0 tests, missing freeaddrinfo, un-zeroed hints, wrong struct casts, and trusting a name without checking where it resolves.