Networking in C · advanced · ~10 min

UDP server

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

Overview

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.

Why it matters

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.

Core concepts

1. Connectionless: no listen, no accept

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

2. bind: claiming an address and port

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

3. recvfrom: the datagram and who sent it

recvfrom 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. recvfrom copies exactly 4 bytes and returns 4; it does not append a \0. Bytes 4..1499 are whatever garbage was there before. If you printf("%s") it you read past the data into uninitialized memory. You must terminate it yourself: buf[n] = '\0'; — which also means you must receive into sizeof buf - 1 so byte n is always in range.

4. Replying: use the address you were handed

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.

5. Truncation and the datagram boundary

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.

6. Defensive posture (lab-only)

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.

Syntax notes

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.

Lesson

How a UDP server differs from TCP

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.

Receiving a datagram

To read incoming data, you call recvfrom.

  • recvfrom blocks (waits) until a datagram arrives.
  • When one arrives, it copies the data into your buffer.
  • It also fills in the sender's address, so you know who sent the message and can reply to them.

Code examples

// 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;
}

Line by line

  • 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.
  • Second 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.
  • Client's 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.

Common mistakes

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);

Debugging tips

  • 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.
  • Datagram never arrives? Confirm both sides use the same port and address family, and that a host firewall isn't dropping it. On loopback, run 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.
  • See the wire: on Linux 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.
  • Hang forever? 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.
  • Garbage in the payload: almost always a missing buf[n] = '\0' or reading sizeof buf instead of the returned n. Print n explicitly and hex-dump the first n bytes.

Memory safety

  • No terminator, ever. Datagram bytes are raw. Reading into 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.
  • Truncation is silent. If the datagram exceeds your buffer, the tail is discarded; never assume you received a whole application message just because recvfrom returned. Validate n against the minimum size your protocol needs before indexing into the buffer.
  • Trust boundary. 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)).
  • Uninitialized address structs. Zero-init sockaddr_in with = {0} so padding/sin_zero fields are defined; comparing or hashing sender addresses with garbage padding leads to nondeterministic behavior.
  • Value-result args. Always reset fromlen = sizeof from before each recvfrom in a loop — the previous call may have shrunk it.
  • Concurrency: if multiple threads share one UDP socket, 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.

Real-world uses

  • DNS (port 53): the canonical UDP request/response server — small query in, answer out — and the textbook amplification vector, which is why real resolvers rate-limit and validate aggressively.
  • DHCP, NTP, SNMP, TFTP, syslog: classic single-datagram services where connection setup would be wasteful overhead.
  • QUIC / HTTP-3: modern web transport runs on top of UDP, doing its own reliability, ordering, and encryption in userspace.
  • Real-time media and games: voice, video, and game state prefer UDP because a dropped packet is better than a delayed one; they tolerate loss instead of waiting for retransmits.

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.

Practice tasks

  1. Echo once. Modify the demo so the server, instead of always replying "ack", echoes the exact received payload back to the client. Verify the client prints the same string it sent.
  2. Fixed port + separate client. Split the program into two files: a server that binds 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.
  3. Uppercase service. Extend the server to transform each received datagram to uppercase and send it back, correctly handling a datagram that is exactly buffer-sized (prove you never write out of bounds).
  4. Truncation detector. Shrink the receive buffer to 8 bytes and send a 20-byte datagram. Detect and report that the message was truncated (hint: compare against expected length; on Linux inspect the MSG_TRUNC flag by passing a msghdr to recvmsg).
  5. Defensive rate limiter (lab-only). Track sender addresses in a small table and drop (do not reply to) any source that sends more than N datagrams per second, so a single spoofed-looking flood on loopback cannot make your server emit unbounded replies. Log dropped sources.

Summary

  • A UDP server is just socket → bind → loop on recvfrom/sendto. There is no listen and no accept because UDP is connectionless.
  • One socket serves all clients; the sender is not implied by the fd, so 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.
  • Ports and addresses go on the wire in network byte order (htons/htonl).
  • The sender address and payload are untrusted. Bind loopback in labs, validate every byte, cap and proportion replies, and rate-limit to avoid becoming an amplification vector.

Practice with these exercises