Networking in C · advanced · ~10 min
- By the end you can create a datagram socket and `bind` it to an address and port so the OS delivers datagrams to your program. - By the end you can receive a datagram with `recvfrom` and recover the *sender's* address so you can reply. - By the end you can explain why a UDP server needs no `listen`/`accept`, and how that changes the server loop compared to TCP. - By the end you can handle the practical hazards: fixed-size buffers, no message terminator, truncation, and byte order. - By the end you can reason about the defensive risks of an internet-facing UDP server (spoofing, amplification) and code to reduce them.
You already built a UDP client in the previous lesson: you created a SOCK_DGRAM socket and used sendto to fire a datagram at a server's address, with no connection setup. A UDP server is the mirror image. Instead of choosing a destination and sending, it claims a well-known address with bind and then waits for datagrams to arrive with recvfrom. The socket call itself is identical to the client's; the difference is that the server pins itself to a fixed port so clients know where to reach it.
This lesson builds directly on that client knowledge. Where the client picked an ephemeral source port automatically, the server explicitly binds. Where the client knew its destination in advance, the server learns each sender's address at receive time and uses it to reply. Everything else — address structs, htons, network byte order — is the same machinery you already met.
UDP servers sit under an enormous amount of real infrastructure: DNS resolvers, DHCP, NTP time sync, SNMP monitoring, QUIC/HTTP-3, and most real-time game and voice/video traffic. Because UDP is connectionless and stateless, a single spoofed request packet can trick a naive server into blasting a large reply at a forged victim address — the mechanism behind DNS and NTP amplification DDoS attacks. Understanding exactly what recvfrom hands you (including an untrusted sender address) is the difference between a server that validates and rate-limits and one that becomes a weapon. Getting the buffer handling right also matters: a UDP datagram has no length prefix and no terminator, so off-by-one and truncation bugs are common and security-relevant.
listen, no acceptA TCP server is a four-step ceremony: socket → bind → listen → accept. Each accepted connection is a brand-new socket representing one client. A UDP server is only two steps: socket → bind. There is no handshake, no connection object, and therefore nothing to listen for or accept. One socket receives datagrams from every client, interleaved in arrival order.
TCP server UDP server
---------- ----------
socket() socket()
bind() bind()
listen() <-- queue conns
accept() <-- 1 sock/client (none of these)
recv()/send() on per-client sock recvfrom()/sendto() on the ONE sock
Because there is a single socket, the sender's identity is not implied by the file descriptor — you must ask for it on every receive. That is what recvfrom's address-out parameter is for.
bind: claiming an address and portbind associates your socket with a local (IP, port). Clients send to that port, and the kernel routes matching datagrams to your socket's receive queue. The address you bind determines which interfaces you listen on:
| Bind address | Constant | Reachable from |
|---|---|---|
127.0.0.1 |
INADDR_LOOPBACK |
this machine only (safe for labs) |
0.0.0.0 |
INADDR_ANY |
every interface, including the network |
| a specific NIC IP | (that address) | just that interface |
port 0 |
— | OS picks a free ephemeral port |
For learning and testing, always bind loopback (127.0.0.1). Binding 0.0.0.0 exposes your program to the whole network and is where the real attack surface lives. Note bind can fail with EADDRINUSE if the port is already taken.
recvfrom: the datagram and who sent itrecvfrom blocks until one datagram is queued, then copies its bytes into your buffer and, crucially, writes the sender's socket address into an output struct:
recvfrom(fd, buf, buflen, flags, &from, &fromlen)
| | |
v v v
your buffer filled with in: sizeof(from)
(fixed size) sender addr out: bytes actually written
Key facts: one call returns exactly one datagram (never a partial one, never two coalesced — unlike TCP's byte stream). The return value n is that datagram's length in bytes. There is no \0 terminator — the bytes are raw. fromlen is a value-result argument: you set it to sizeof from going in, and the kernel overwrites it with the actual address size coming out.
Knowledge check: A client sends the 4 bytes p i n g. Your server calls recvfrom into char buf[1500]. Is buf now a valid C string?
No.
recvfromcopies exactly 4 bytes and returns4; it does not append a\0. Bytes 4..1499 are whatever garbage was there before. If youprintf("%s")it you read past the data into uninitialized memory. You must terminate it yourself:buf[n] = '\0';— which also means you must receive intosizeof buf - 1so bytenis always in range.
Since there is no connection, to answer a client you call sendto with the exact from address recvfrom just filled in. Pass the same fromlen. This is why capturing the sender address is not optional cleverness — it is the only way a UDP server can talk back.
If a datagram is larger than your buffer, recvfrom copies what fits and silently discards the rest (setting MSG_TRUNC in the returned flags on Linux if you ask). The lost bytes are gone — UDP will not resend them. Size your buffer for the maximum datagram you expect; 1500 (a typical Ethernet MTU) or 65507 (the largest possible UDP payload) are common choices.
The address recvfrom gives you is attacker-controlled: UDP has no handshake, so a sender can spoof the source IP. Never trust it for authorization, and be careful about reply size. A server that returns a big response to a small spoofed request is an amplifier. Defensive rules: bind loopback while developing; validate/parse every byte before acting; cap reply size relative to request size; rate-limit per source; and treat the payload as hostile input, not a C string. All examples here run on 127.0.0.1 only.
int socket(int domain, int type, int protocol);
// AF_INET (IPv4), SOCK_DGRAM (UDP), 0 (default proto).
// Returns a file descriptor >= 0, or -1 (errno set). Must close().
int bind(int fd, const struct sockaddr *addr, socklen_t addrlen);
// Associates fd with a local (IP, port). Cast &sockaddr_in to sockaddr*.
// Returns 0 on success, -1 on error (EADDRINUSE, EACCES for low ports).
ssize_t recvfrom(int fd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
// Blocks for one datagram. Copies up to len bytes into buf.
// src_addr: OUT, filled with sender's address (pass NULL to ignore).
// addrlen : IN/OUT value-result — set to sizeof(*src_addr) before call.
// Returns byte count of the datagram (0 = empty datagram), or -1.
ssize_t sendto(int fd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
// Sends one datagram to dest_addr (the sender you captured, to reply).
// Returns bytes sent, or -1.
int getsockname(int fd, struct sockaddr *addr, socklen_t *addrlen);
// Reads back the local address actually bound — useful after bind(port 0).
// Byte-order & address helpers you already know from the client lesson:
uint16_t htons(uint16_t); // host -> network (big-endian) for ports
uint32_t htonl(uint32_t); // host -> network for addresses (e.g. INADDR_LOOPBACK)
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
// Formats a binary address as text; NULL on error. dst must be >= INET_ADDRSTRLEN.
Error convention throughout: negative return means failure and sets errno; check every call. Every socket fd must eventually be close()d.
A UDP server follows the same basic idea as a TCP server, but it is simpler in one key way: there is no listen or accept step.
This is because UDP is connectionless. There is no handshake and no persistent connection between the two sides. Each message (called a datagram) is sent and received on its own.
To read incoming data, you call recvfrom.
recvfrom blocks (waits) until a datagram arrives.// udp_echo_demo.c — a hermetic UDP server demo on the loopback interface.
// One process plays BOTH roles: it binds a UDP "server" socket, then a
// separate "client" socket sends two datagrams to it. The server reads
// each with recvfrom(), learns the sender's address, and echoes a reply.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
static void die(const char *msg) { perror(msg); exit(1); }
int main(void) {
// --- server side: create an unconnected datagram socket ---
int srv = socket(AF_INET, SOCK_DGRAM, 0);
if (srv < 0) die("socket(srv)");
struct sockaddr_in saddr = {0};
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1, lab-only
saddr.sin_port = htons(0); // 0 = let the OS pick a free port
if (bind(srv, (struct sockaddr *)&saddr, sizeof saddr) < 0) die("bind");
// Read back the port the kernel actually assigned.
struct sockaddr_in bound = {0};
socklen_t blen = sizeof bound;
if (getsockname(srv, (struct sockaddr *)&bound, &blen) < 0) die("getsockname");
printf("server bound to 127.0.0.1:%u\n", ntohs(bound.sin_port));
// --- client side: another datagram socket, no bind() needed ---
int cli = socket(AF_INET, SOCK_DGRAM, 0);
if (cli < 0) die("socket(cli)");
const char *messages[2] = { "ping", "hello udp" };
for (int i = 0; i < 2; i++) {
// Client sends a datagram to the server's bound address.
ssize_t sent = sendto(cli, messages[i], strlen(messages[i]), 0,
(struct sockaddr *)&bound, sizeof bound);
if (sent < 0) die("sendto");
// --- server receives the datagram AND the sender's address ---
char buf[1500];
struct sockaddr_in from = {0};
socklen_t flen = sizeof from;
ssize_t n = recvfrom(srv, buf, sizeof buf - 1, 0,
(struct sockaddr *)&from, &flen);
if (n < 0) die("recvfrom");
buf[n] = '\0'; // datagrams carry no terminator; add one for printing
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
printf("recv %zd bytes from %s:%u -> \"%s\"\n",
n, ip, ntohs(from.sin_port), buf);
// Server replies to whoever sent the datagram, using `from`.
const char *reply = "ack";
if (sendto(srv, reply, strlen(reply), 0,
(struct sockaddr *)&from, flen) < 0) die("sendto(reply)");
// Client reads the echo back.
char rbuf[1500];
ssize_t rn = recvfrom(cli, rbuf, sizeof rbuf - 1, 0, NULL, NULL);
if (rn < 0) die("recvfrom(cli)");
rbuf[rn] = '\0';
printf("client got reply: \"%s\"\n", rbuf);
}
close(cli);
close(srv);
return 0;
}
static void die(...): a tiny helper — every syscall below is checked, and on failure we perror (which prints the message plus the errno text) and exit. Unchecked socket calls are the #1 source of silent networking bugs.socket(AF_INET, SOCK_DGRAM, 0): creates the server socket. SOCK_DGRAM selects UDP. This is byte-for-byte the same call the client makes — the socket type does not encode 'server' vs 'client'.struct sockaddr_in saddr = {0}: zero-initialize so no field is left as garbage. sin_family = AF_INET marks it IPv4.htonl(INADDR_LOOPBACK): bind to 127.0.0.1, reachable only from this machine — the safe, defensive default. htonl converts to network byte order.htons(0): port 0 tells the kernel to assign any free port. In a real server you would put your fixed service port here (e.g. htons(9000)).bind(...): claims the address. Cast &saddr to struct sockaddr * because the generic socket API predates sockaddr_in. From here on, datagrams sent to that port land in this socket's queue.getsockname(...) + printf: since we bound port 0, we ask the kernel which port it actually chose, so the client half knows where to send. ntohs converts the port back to host order for printing.socket(...) for cli: the client socket. Notice it is not bound — a client's source port is auto-assigned on first sendto.sendto(cli, ..., &bound, ...): the client fires a datagram at the server's discovered address. One sendto == one datagram.recvfrom(srv, buf, sizeof buf - 1, 0, &from, &flen): the heart of the server. It blocks until the datagram arrives, returns its length n, and fills from with the client's address. We read into sizeof buf - 1 so there is always room for the terminator at index n.buf[n] = '\0': the datagram has no terminator; we add one so %s is safe. This line is load-bearing, not cosmetic.inet_ntop(...) + printf: format the captured sender IP and port as text so you can see that the server learned who sent the message.sendto(srv, reply, ..., &from, flen): the server replies to the exact address it captured. This is the only way UDP can answer — there is no connection to send back through.recvfrom(cli, ..., NULL, NULL): the client reads the echo; passing NULL for the address args says 'I don't care who replied.'close(cli); close(srv): release both file descriptors.1. Treating the buffer as a string without terminating it
ssize_t n = recvfrom(s, buf, sizeof buf, 0, ...);
printf("%s\n", buf); // WRONG: no '\0', reads past the datagram
Why it breaks: recvfrom copies exactly n bytes and adds no terminator; %s runs off into uninitialized memory (info leak / crash). Also, receiving into full sizeof buf leaves no room for a terminator at index n.
ssize_t n = recvfrom(s, buf, sizeof buf - 1, 0, ...);
if (n < 0) { perror("recvfrom"); return 1; }
buf[n] = '\0';
printf("%s\n", buf); // FIXED
2. Forgetting to set fromlen before the call
socklen_t flen; // uninitialized garbage
recvfrom(s, buf, len, 0, (struct sockaddr*)&from, &flen); // WRONG
Why it breaks: fromlen is value-result. The kernel reads it as 'how big is your buffer' first; garbage there means it may refuse to write the address or truncate it.
socklen_t flen = sizeof from; // FIXED: set to capacity first
recvfrom(s, buf, len, 0, (struct sockaddr*)&from, &flen);
3. Adding listen/accept to a UDP server
int s = socket(AF_INET, SOCK_DGRAM, 0);
bind(s, ...);
listen(s, 5); // WRONG: fails with EOPNOTSUPP on a datagram socket
accept(s, ...);
Why it breaks: those calls are for connection-oriented (SOCK_STREAM) sockets. UDP has no connections to queue or accept.
int s = socket(AF_INET, SOCK_DGRAM, 0);
bind(s, ...);
recvfrom(s, ...); // FIXED: go straight to receiving
4. Port/address in host byte order
saddr.sin_port = 9000; // WRONG on little-endian hosts
saddr.sin_addr.s_addr = INADDR_LOOPBACK; // WRONG: needs htonl
Why it breaks: the network expects big-endian; on x86/ARM 9000 becomes a different port number, and clients can't reach you.
saddr.sin_port = htons(9000); // FIXED
saddr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bind fails with EADDRINUSE? A previous run still holds the port, or another process owns it. Check with lsof -i :PORT (macOS/Linux) or ss -lunp (Linux). Use setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, ...) when appropriate, or bind port 0 while testing.nc -u 127.0.0.1 PORT as a manual client to poke your server, and printf the value of n and the parsed from address to prove what actually arrived.sudo strace -e trace=network ./server shows every socket/bind/recvfrom with its arguments and return value — perfect for spotting a byte-order or fromlen mistake. tcpdump -i lo0 udp port PORT (macOS) / -i lo (Linux) shows the packets themselves.recvfrom blocks by default. If nothing is being sent, that's expected — attach with gdb -p PID and bt to confirm it is parked in recvfrom. For non-blocking behavior use O_NONBLOCK or a poll/select timeout.buf[n] = '\0' or reading sizeof buf instead of the returned n. Print n explicitly and hex-dump the first n bytes.sizeof buf (not sizeof buf - 1) and then writing buf[n] = '\0' writes one byte past the buffer when the datagram exactly fills it — a stack overflow. Always receive into sizeof buf - 1, or terminate only after bounds-checking n < sizeof buf.recvfrom returned. Validate n against the minimum size your protocol needs before indexing into the buffer.from is filled from attacker-controlled network data and the payload is untrusted input. Do not use the payload as a length, index, or format string without validation (printf(buf) is a classic format-string bug — use printf("%s", buf)).sockaddr_in with = {0} so padding/sin_zero fields are defined; comparing or hashing sender addresses with garbage padding leads to nondeterministic behavior.fromlen = sizeof from before each recvfrom in a loop — the previous call may have shrunk it.recvfrom is atomic per datagram (no interleaving of one message), but two threads racing to recvfrom will each grab different datagrams — give each thread its own from/buf locals, never a shared buffer.Best practice for production UDP servers: bind the specific interface you intend (not blindly 0.0.0.0); validate every field of every datagram before acting; enforce a maximum request size and keep replies proportional to requests to avoid becoming an amplifier; rate-limit per source address; consider connect()-ing the socket to a single peer when you only talk to one, so the kernel filters stray senders; and use poll/epoll with timeouts rather than a bare blocking recvfrom in anything that must stay responsive.
"ack", echoes the exact received payload back to the client. Verify the client prints the same string it sent.127.0.0.1:9000 and loops forever printing each datagram and its sender, and a standalone client that sends one message from argv[1]. Test them in two terminals.MSG_TRUNC flag by passing a msghdr to recvmsg).socket → bind → loop on recvfrom/sendto. There is no listen and no accept because UDP is connectionless.recvfrom hands you the sender's address on every call — use it to reply with sendto.recvfrom returns exactly one datagram, its length n, and no terminator. Receive into sizeof buf - 1 and set buf[n] = '\0' before treating it as text.fromlen is a value-result arg: set it to sizeof from before every call.htons/htonl).