Build a TCP Echo Server in C

Build a multi-client TCP echo server in C using the POSIX socket API.

Introduction

30 Second Summary

Every message you send, every website you load, and every file you download passes through a socket. Understanding how sockets work at the C level is the foundation for every networking and security tool you will build.

In this project, you will build a TCP echo server and client from scratch in C. Along the way, you will diagnose a real networking bug with Linux system tools and fix it so your server handles multiple clients at once.

What You'll Build

You type a message in your terminal, hit enter, and watch the server echo it right back across a TCP connection you built from raw C system calls.

By the end of this project, you'll have:

  • A live TCP echo server you can connect to from any terminal. Type a message and see it bounce right back.
  • Your own C client that connects to the server, sends your input over the network, and displays every echoed response.
  • First-hand experience diagnosing a blocking I/O bug. You will watch a second client hang, trace the stuck system call, and fix it with process forking so every client gets its own process.
  • Secret Mission: an optional challenge to push your skills further.

Are there any prerequisites?

This project assumes basic C knowledge: variables, functions, loops, structs, and pointers. You also need a running Kali Linux VM.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your cybersecurity path.

This project puts you inside the Linux networking stack at the C level, building the same socket primitives that tools like Nmap, Wireshark, and Metasploit use under the hood.

Set Up Your C Development Environment

Your Kali Linux VM gives you a Linux kernel and a shell, but it does not always ship with a C compiler, a debugger, or a system call tracer.

You need all three before you can write or diagnose any networking code. In this step, you will install them and prove the toolchain works by compiling a small test program.

In this step, get ready to:
  • Install GCC, GDB, and strace on your Kali Linux VM.
  • Create a project directory and compile a test C program.
Install build-essential, GDB, and strace

Three tools form your development toolkit for this project. GCC compiles your C source code into executables. GDB lets you pause a running program and inspect what is in memory.

strace traces every system call your program makes to the Linux kernel. You will use all three throughout this project.

  • Install all three by running this command:
sudo apt update && sudo apt install -y build-essential gdb strace

What does this command do?

  • sudo apt update refreshes the package list so your system knows about the latest available versions.
  • build-essential pulls in GCC, make, and the C library headers you need to compile C programs.
  • gdb installs the GNU Debugger.
  • strace installs the system call tracer.
  • The -y flag auto-confirms the install so you are not prompted to type "yes".
  • Confirm all three tools installed successfully by running:
gcc --version && gdb --version && strace --version

Each tool prints its version information. If any command fails, the && operator stops the chain at the tool that is missing.

Not seeing version output for all three?

  • Check that each --version uses two dashes, not one.
  • If sudo apt install failed earlier, check your internet connection and try running sudo apt update again before re-running the install command.
  • Help me troubleshoot installing build-essential, gdb, and strace on Kali Linux.
Create the project directory and verify GCC

Every file you write in this project lives in a single directory called tcp-echo inside your home folder.

  • Create the project directory and move into it by running:
mkdir -p ~/tcp-echo && cd ~/tcp-echo
  • Confirm you are in the right directory by running:
pwd

You should see a path ending in /tcp-echo.

  • Create a file called hello.c by opening it in nano:
nano hello.c

nano opens a text editor inside your terminal. You will see an empty file with keyboard shortcuts listed at the bottom of the screen.

  • Type the following code into the editor:
#include <stdio.h>
int main(void) {
    printf("Hello, world!\n");
    return 0;
}

What does this code do?

  • #include <stdio.h> imports the standard I/O library so you can use printf.
  • int main(void) is the entry point of every C program. The operating system calls this function when you run the executable.
  • return 0 tells the operating system the program finished successfully.
  • Save the file by pressing Ctrl+O, then Enter to confirm the filename.
  • Exit nano by pressing Ctrl+X.
  • Confirm the file was created by running:
ls

You should see hello.c listed in the output.

Don't see hello.c?

  • Make sure you pressed Ctrl+O (the letter O, not zero) to save before exiting nano.
  • Run pwd to confirm you are still in the ~/tcp-echo directory. If you opened a new terminal tab, you need to run cd ~/tcp-echo again.
  • Help me figure out why hello.c was not created in my tcp-echo directory.

✔️ Awesome, I've got everything!

Great. Double check you have saved hello.c and exited nano before continuing.

ⓧ I'd like to double check the full code

Here is the complete hello.c file. Compare it against what you typed in nano.

#include <stdio.h>
int main(void) {
    printf("Hello, world!\n");
    return 0;
}

If anything looks different, open the file again with nano hello.c, fix the code, then save and exit.

  • Compile hello.c with debug symbols by running:
gcc -g -Wall -o hello hello.c

What do these flags do?

  • -g embeds debug information in the executable so GDB can map memory addresses back to your source code lines.
  • -Wall enables all compiler warnings. If you make a mistake, GCC tells you instead of silently compiling broken code.
  • -o hello names the output executable hello instead of the default a.out.

If the command succeeds, you return to your terminal prompt with no output. That means no errors.

Before you run the program, what do you think it will print?

  • Run your compiled program:
./hello

You see Hello, world! printed in the terminal. Your C toolchain is working.

Seeing a compilation error?

  • Open hello.c again with nano hello.c and check for missing semicolons, mismatched parentheses, or typos in printf.
  • Make sure the first line is #include <stdio.h> with angle brackets, not quotes or parentheses.
  • GCC gave me an error when compiling hello.c. Help me understand what went wrong.

Your development environment is ready. Next up, you will write the echo server itself and watch it send messages back to a connected client.

Write and Run the Echo Server

Your C toolchain is installed and your project folder is ready. Now it's time to build the core of this project: the TCP echo server.

The server's job is to listen for incoming connections on a port, receive messages from a client, and send those exact messages right back. Getting this working proves the POSIX socket API is doing its job and gives you a live result you can interact with immediately.

In this step, get ready to:
  • Write the server source code in server.c.
  • Compile and run the echo server.
  • Test the server by sending messages with netcat.
Write server.c

Every TCP server follows the same sequence of system calls: create a socket, bind it to an address and port, listen for connections, then accept clients. You are about to write each of these calls from scratch.

  • Create server.c with nano by running this command:
nano server.c

Your first chunk of code sets up the server's foundation: headers, constants, variable declarations, socket creation, and port reuse. Try writing it yourself using the man pages as a guide, or open the help tab for the full code.

✔️ I'll give it a go!

Here is what this chunk needs to accomplish:

  • Include the seven headers for the POSIX socket API and standard C library (stdio.h, stdlib.h, string.h, unistd.h, sys/socket.h, netinet/in.h, arpa/inet.h).
  • Define PORT as 9090 and BUFFER_SIZE as 1024.
  • Open main() and declare variables for the server and client file descriptors, address structs, a character buffer, and a bytes-read counter.
  • Create a TCP socket with socket() using AF_INET and SOCK_STREAM, and check the return value for errors.
  • Enable port reuse with setsockopt() and SO_REUSEADDR so restarts do not fail with "Address already in use".

Useful references

  • socket(2) man page — covers AF_INET, SOCK_STREAM, and the function signature.
  • ip(7) man page — covers struct sockaddr_in fields, SO_REUSEADDR, and the required headers.

ⓧ Maybe a little help...

Paste the following code into the empty file:

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

#define PORT 9090
#define BUFFER_SIZE 1024

int main(void) {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len;
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;

    // Create a TCP socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // Allow port reuse so restarts don't fail
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

What does this code do?

  • The seven #include headers pull in the C standard library functions and the POSIX socket API types you need.
  • socket(AF_INET, SOCK_STREAM, 0) asks the kernel to create a TCP socket. AF_INET means IPv4. SOCK_STREAM means TCP (reliable, ordered byte stream).
  • SO_REUSEADDR tells the kernel to let you rebind to the same port immediately after stopping the server. Without it, you would get an "Address already in use" error for up to 60 seconds after each restart.

Next, configure the server's address, bind the socket to it, and start listening. Try writing it from the man pages, or open the help tab for the code.

✔️ I'll give it a go!

Directly below the setsockopt line, write the code that does the following:

  • Zero out the server_addr struct with memset, then set sin_family to AF_INET, sin_addr.s_addr to INADDR_ANY, and sin_port to htons(PORT).
  • Call bind() to attach the socket to the address and port. Check for errors.
  • Call listen() with a backlog of 5 to mark the socket as ready for incoming connections. Check for errors.
  • Print a message confirming the server is listening.

Useful references

ⓧ Maybe a little help...

Directly below the setsockopt line, paste this code:


    // Configure the server address struct
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // Bind the socket to the address and port
    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // Start listening for incoming connections
    if (listen(server_fd, 5) < 0) {
        perror("listen");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    printf("Server listening on port %d\n", PORT);

What does this code do?

  • struct sockaddr_in holds the IP address and port. INADDR_ANY means "accept connections on any network interface." htons(PORT) converts the port number from your machine's byte order to network byte order.
  • bind() attaches the socket to the address and port you configured. listen() marks the socket as ready to accept incoming connections, with a backlog queue of 5.

💡 Why network byte order?

Different CPUs store bytes in different orders (big-endian vs little-endian). Network protocols standardize on big-endian. htons() (host to network short) converts your port number so every machine on the network reads it the same way.

The final chunk is the server's main loop: accepting clients, printing their info, and echoing their messages. Try writing it from the docs, or open the help tab.

✔️ I'll give it a go!

Directly below the printf line, write the accept loop and echo logic:

  • Wrap everything in a while (1) loop.
  • Call accept() to wait for a client connection. It returns a new file descriptor for the connected client.
  • Print the client's IP with inet_ntoa() and port with ntohs().
  • Write an inner while loop that calls recv() to read data from the client, then immediately send() those same bytes back. Exit the loop when recv() returns 0 (client disconnected).
  • Close the client socket after the inner loop ends, then close the server socket and return 0 after the outer loop.

Useful references

ⓧ Maybe a little help...

Directly below the printf line, paste this code to finish the program:


    // Main loop: accept one client at a time
    while (1) {
        client_len = sizeof(client_addr);
        client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
        if (client_fd < 0) {
            perror("accept");
            continue;
        }

        // Print the client's IP and port
        printf("Client connected: %s:%d\n",
               inet_ntoa(client_addr.sin_addr),
               ntohs(client_addr.sin_port));

        // Echo loop: receive data and send it right back
        while ((bytes_read = recv(client_fd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
            buffer[bytes_read] = '\0';
            printf("Received: %s", buffer);
            send(client_fd, buffer, bytes_read, 0);
        }

        printf("Client disconnected\n");
        close(client_fd);
    }

    close(server_fd);
    return 0;
}

What does this code do?

  • accept() blocks until a client connects. It returns a new file descriptor for that specific client connection, separate from the listening socket.
  • inet_ntoa() and ntohs() convert the client's binary IP address and port back into human-readable form so you can see who connected.
  • The inner while loop calls recv() to read data from the client, then immediately send() sends those same bytes back. When the client disconnects, recv() returns 0 and the loop exits.
  • Save and exit nano by pressing Ctrl+X, then Y to confirm, then Enter.
  • Confirm the file was created by running:
ls

You should see server.c listed alongside hello.c and hello from the previous step.

✔️ Awesome, I've got everything!

Great. Double check you saved the file, then move on to compiling.

ⓧ I'd like to double check the full code

The full file is built from three code blocks above. Scroll up and confirm each is present in order:

  • Block 1: The #include headers, #define constants, main() opening with variable declarations, socket creation, and SO_REUSEADDR.
  • Block 2: Address configuration with memset, bind(), listen(), and the "Server listening" printf.
  • Block 3: The while (1) accept loop, the inner recv()/send() echo loop, and the closing braces with return 0.

If anything looks off, reopen the file with nano server.c and fix it before compiling.

Don't see server.c?

  • Make sure you pressed Ctrl+X, then Y, then Enter to save in nano. Pressing N instead of Y discards your changes.
  • Check you are in the ~/tcp-echo directory by running pwd. If you are somewhere else, run cd ~/tcp-echo and try ls again.

Still stuck? Help me figure out why server.c didn't save in nano

Compile and test with netcat

The code is written. Time to compile it into a binary and prove it actually echoes messages over the network.

  • Compile server.c with debug symbols by running:
gcc -g -Wall -o server server.c

What do these flags do?

  • -g embeds debug information so GDB can map addresses to source lines later.
  • -Wall turns on all compiler warnings so you catch mistakes early.
  • -o server names the output binary server instead of the default a.out.

If the command completes with no output, the compile succeeded. Warnings or errors mean something needs fixing in your code.

Seeing compile errors?

  • GCC prints the file name, line number, and a description for each error. Open server.c with nano server.c and go to the line number shown. Common causes: a missing semicolon, a mismatched brace, or a typo in a function name.
  • Check that all three code blocks from the previous substep are present and in order. The file should start with #include <stdio.h> and end with a closing }.

Need a hand? Help me fix GCC compile errors in my server.c

  • Start the server by running:
./server

You should see the message Server listening on port 9090 printed to the terminal. The server is now waiting for a client to connect. Your terminal is blocked because the server is running in the foreground.

Now you need a second terminal to connect as a client. You will use netcat (nc), a networking utility that is pre-installed on Kali Linux.

  • Open a second terminal window. On Kali, right-click the desktop and select Open Terminal Here, or press Ctrl+Alt+T.
  • Connect to your server with netcat by running:
nc 127.0.0.1 9090

What does this command do?

nc opens a raw TCP connection to the IP address 127.0.0.1 (your own machine, also called the loopback address) on port 9090. Anything you type gets sent over that connection.

Before you type a message, what do you think will happen when you press Enter? Will the text appear in your netcat terminal, the server terminal, or both?

  • Type hello from netcat in the netcat terminal and press Enter.

Your message appears right back in the netcat terminal. That is the echo. Over in the server terminal, you should see two lines: the client's connection info (IP and port) and the message it received.

  • Type a few more messages and watch them echo back each time.
  • When you are done, press Ctrl+C in the netcat terminal to disconnect.
  • Switch to the server terminal. You should see Client disconnected printed.
  • Press Ctrl+C in the server terminal to stop the server.

Connection refused when running nc?

  • Make sure the server is still running in the first terminal. You should see Server listening on port 9090 printed there. If the server exited, restart it with ./server.
  • Double check you typed 127.0.0.1 and 9090 correctly in the nc command.

Still not connecting? Help me troubleshoot why netcat can't connect to my server

Your echo server is live and responding to messages over the network. Next up, you will write your own TCP client in C to replace netcat and dig into what the socket struct actually looks like in memory.

Write the TCP Client

Your server echoes messages to anyone who connects. You proved it with netcat in the last step. Now you will build the other half of the conversation: a TCP client written in C.

Writing your own client teaches the client side of the socket API. Instead of bind, listen, and accept, the client uses connect. You will also use GDB to look at the sockaddr_in struct at the raw byte level.

In this step, get ready to:
  • Write a TCP client in C that connects to the server and sends messages.
  • Compile and test the client against the running server.
  • Use GDB to inspect the sockaddr_in struct in memory.
Write client.c

The client follows the same socket setup pattern you used in server.c, but its job is simpler. It creates a socket, fills a struct sockaddr_in with the server's address, and calls connect instead of bind and listen.

  • Make sure you are in the ~/tcp-echo directory.
  • Create a new file called client.c by opening it in nano:
nano client.c

The client's first chunk is similar to the server's setup. Try writing it yourself using the man pages, or open the help tab for the code.

✔️ I'll give it a go!

Here is what this chunk needs to accomplish:

  • Include the same seven headers you used in server.c.
  • Define PORT as 9090 and BUFFER_SIZE as 1024.
  • Open main() and declare variables: a socket file descriptor, a sockaddr_in for the server address, a character buffer, and a bytes-read counter.
  • Create a TCP socket with socket(AF_INET, SOCK_STREAM, 0) and check for errors.
  • Zero out the server_addr struct with memset, set sin_family to AF_INET, and set sin_port to htons(PORT).

Useful references

ⓧ Maybe a little help...

Paste the following code into the empty file:

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

#define PORT 9090
#define BUFFER_SIZE 1024

int main(void) {
    int sock_fd;
    struct sockaddr_in server_addr;
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;

    // Create a TCP socket
    sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (sock_fd < 0) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // Set the server address: IPv4, port 9090, loopback
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);

What does this code do?

  • The headers are identical to server.c. Both sides of a TCP connection use the same socket library.
  • socket(AF_INET, SOCK_STREAM, 0) creates a TCP socket, just like in the server.
  • The server_addr struct tells the client WHERE to connect. The server used INADDR_ANY (any address), but the client needs a specific target IP and port.
  • htons(PORT) converts port 9090 to network byte order, same as the server side.

The second chunk converts the IP address, connects to the server, and runs the send/receive loop. This is where the client differs most from the server. Try it from the docs, or open the help tab.

✔️ I'll give it a go!

Directly below the code you just wrote, add the rest of the client:

  • Use inet_pton() to convert the string "127.0.0.1" into binary form and store it in server_addr.sin_addr. Check the return value.
  • Call connect() to initiate the TCP handshake with the server. Check for errors.
  • Print a "Connected to server" confirmation message.
  • Write a while loop that reads a line from stdin with fgets(), sends it with send(), reads the echo back with recv(), and prints it with an "Echo: " prefix.
  • Close the socket and return 0 at the end.

Useful references

ⓧ Maybe a little help...

Directly below the code you just pasted, add the rest of the client:


    // Convert "127.0.0.1" from text to binary
    if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) <= 0) {
        perror("inet_pton");
        close(sock_fd);
        exit(EXIT_FAILURE);
    }

    // Connect to the server
    if (connect(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("connect");
        close(sock_fd);
        exit(EXIT_FAILURE);
    }

    printf("Connected to server\n");

    // Read input, send to server, receive echo, print it
    while (fgets(buffer, BUFFER_SIZE, stdin) != NULL) {
        send(sock_fd, buffer, strlen(buffer), 0);
        bytes_read = recv(sock_fd, buffer, BUFFER_SIZE - 1, 0);
        if (bytes_read <= 0) {
            printf("Server disconnected\n");
            break;
        }
        buffer[bytes_read] = '\0';
        printf("Echo: %s", buffer);
    }

    close(sock_fd);
    return 0;
}

What does this code do?

  • inet_pton converts the text string "127.0.0.1" into the binary format the kernel expects. The server used INADDR_ANY (a constant), so it did not need this conversion.
  • connect is the client's counterpart to the server's bind/listen/accept. One call initiates the TCP handshake and establishes the connection.
  • The while loop reads a line from the keyboard with fgets, sends it with send, waits for the echo with recv, and prints it with an "Echo: " prefix.
  • If recv returns 0 or a negative value, the server has disconnected. The client prints a message and breaks out of the loop.
  • Save the file by pressing Ctrl+O, then Enter to confirm the filename.
  • Exit nano by pressing Ctrl+X.
  • Confirm the file exists by running:
ls

You should see client.c alongside server.c and your other files in the listing.

✔️ Awesome, I've got everything!

Great. Double check you have saved client.c and exited nano before moving on.

ⓧ I'd like to double check the full code

Open client.c again with nano client.c and scroll through the file. Confirm these sections appear in order:

  • Seven #include headers at the top, followed by #define PORT 9090 and #define BUFFER_SIZE 1024.
  • int main(void) with four variable declarations (sock_fd, server_addr, buffer, bytes_read).
  • socket() call with error check, then memset and server_addr fields set.
  • inet_pton() converting "127.0.0.1", then the connect() call with error check.
  • A while (fgets(...)) loop containing send, recv, and printf("Echo: %s", buffer).
  • close(sock_fd) and return 0 at the bottom, with the closing }.
Compile and test the client
  • Compile client.c with debug symbols by running:
gcc -g -Wall -o client client.c

Seeing compile errors?

  • Check that every opening brace { has a matching closing brace }. The file should end with exactly one } that closes main.
  • Make sure every line inside main() ends with a semicolon, except lines with { or }.
  • Verify that the two code chunks are pasted in order with no gap or overlap. The second chunk starts with the inet_pton call, directly after the server_addr.sin_port line.

Help me fix my client.c compile errors

Now you need two terminals: one for the server and one for the client.

  • Start the server in your current terminal by running:
./server

You should see Server listening on port 9090.

  • Open a second terminal window.
  • In the second terminal, move into the project directory and run the client:
cd ~/tcp-echo
./client

Before you type a message, what do you expect to see? Will the client print the message as-is, or will something be different about the output?

  • Type hello from my client and press Enter.

The client prints Echo: hello from my client. The Echo: prefix is your client's addition. Over in the server terminal, you see Received: hello from my client along with the client's connection info.

  • Type a few more messages to see them echo back.
  • Press Ctrl+C in the client terminal to disconnect.
  • Press Ctrl+C in the server terminal to stop the server.

Seeing 'Connection refused'?

  • Make sure the server is running in the other terminal before you start the client. You should see Server listening on port 9090 in the server's output.
  • Check that you compiled with gcc -g -Wall -o client client.c (not gcc -g -Wall -o client server.c).

Help me debug my TCP client connection

Inspect the socket struct with GDB

This is where C connects to networking at the byte level. You are about to look at the raw memory of the sockaddr_in struct and see how the address family, port, and IP address are laid out as contiguous bytes.

  • Make sure the server is still running in one terminal. If you stopped it, start it again with ./server.
  • In your second terminal, launch the client under GDB by running:
gdb ./client

GDB loads the client binary and drops you into the (gdb) prompt.

  • Set a breakpoint at the connect function, then start the program by running these two GDB commands:
break connect
run

GDB stops the program right when connect is called. The server_addr struct is already filled with the address, port, and IP at this point.

  • Move up to main's stack frame (where server_addr lives), then inspect the struct:
up
print server_addr

GDB prints the struct fields: sin_family, sin_port, and sin_addr in a human-readable format. Now look at the same struct as raw memory.

  • Examine the raw 16 bytes of the struct by running:
x/16bx &server_addr

What do these 16 bytes mean?

The x/16bx command tells GDB to e(x)amine 16 bytes in he(x)adecimal. Here is what each group of bytes represents:

  • Bytes 0-1: the address family. AF_INET is 2, so you see 0x02.
  • Bytes 2-3: the port in network byte order. Port 9090 is 0x2382, stored as 0x23 0x82 (big-endian, regardless of your CPU).
  • Bytes 4-7: the IP address 127.0.0.1 as 0x7f 0x00 0x00 0x01.
  • Bytes 8-15: padding zeros. The struct is 16 bytes total.

This is what every network tool sees. The struct is just bytes that map to protocol fields.

  • Exit GDB by typing:
quit
  • If GDB asks to confirm, type y and press Enter.
  • Stop the server with Ctrl+C in the server terminal.

GDB says 'No symbol server_addr in current context'?

  • Make sure you typed up before print server_addr. The breakpoint lands inside the library's connect function, not in your main(). The up command moves you back to main's stack frame where server_addr lives.
  • Verify you compiled with the -g flag. Without it, GDB cannot see your variable names.

Help me inspect my sockaddr_in struct in GDB

You now have both halves of a TCP conversation: a server that listens and echoes, and a client that connects and sends. Next up, you will push the server to its limit and discover what happens when a second client tries to connect.

Hit the Wall — One Client at a Time

Your echo server handles one client beautifully. Messages fly back and forth, and everything works exactly as you wrote it.

A real server needs to handle many clients at once. In this step, you'll connect two clients simultaneously and find out whether your server can keep up.

In this step, get ready to:
  • Connect two clients at the same time and observe the server's behavior.
  • Use strace to diagnose exactly where the server gets stuck.
Connect two clients at the same time

You need three terminals open for this test. One runs the server, one runs the first client, and one runs the second client.

  • In terminal 1, navigate to your project directory and start the server by running:
cd ~/tcp-echo
./server

You should see Server listening on port 9090.

  • Open a second terminal. Connect a client and send a test message by running:
cd ~/tcp-echo
./client
  • Type hello from client A and press Enter.

You should see Echo: hello from client A printed back. Leave this client connected and idle. Do not type anything else in this terminal.

Now open a third terminal. Before you type a message in this new client, think about what you expect to happen. Will the server echo messages from a second client while the first one is still connected?

  • Connect a second client by running:
cd ~/tcp-echo
./client

You'll see Connected to server printed. The TCP connection succeeded at the kernel level.

  • Type hello from client B and press Enter.

Nothing happens. The cursor sits there. No echo comes back. Your second client is frozen.

Why did the second client freeze?

The second client's connect() call succeeded because the kernel accepted the TCP connection into the server's listen backlog. The client thinks it's connected.

But the server code never reached accept() for client B. It is stuck somewhere else. You will pinpoint exactly where in the next substep.

Now prove that the first client is the bottleneck.

  • Switch to terminal 2 (the first client).
  • Press Ctrl+C to disconnect client A.
  • Immediately look at terminal 3 (the second client).

The moment client A disconnects, client B's message echoes back. The server was only able to process client B after client A released it.

  • Press Ctrl+C in terminal 3 to disconnect client B.
  • Press Ctrl+C in terminal 1 to stop the server.

Second client got an echo immediately?

Make sure client A is still connected and idle when you type in client B. If client A disconnected before you sent the message, the server was free to process client B right away.

Restart the test. Send one message in client A, confirm the echo, then let client A sit without typing anything. Only then send a message in client B.

Still not seeing the hang? Help me figure out why my second client is not freezing.

Diagnose the block with strace

You saw the symptom. Now you need proof of the cause. strace traces the system calls your program makes to the Linux kernel, so you can see exactly which call the server is stuck on.

  • In terminal 1, start the server again by running:
./server
  • In terminal 2, connect a client by running:
./client
  • Type one message and confirm it echoes back.
  • Leave client A connected and idle. Do not type anything else.

Now you'll attach strace to the running server process. What system call do you think the server is blocked on right now?

  • In terminal 3, attach strace to the server process by running:
strace -p $(pgrep -x server) -e trace=recv

What does this command do?

  • pgrep -x server finds the process ID of the running server binary.
  • -p tells strace to attach to that already-running process.
  • -e trace=recv filters the output to show only recv system calls, cutting through the noise.

Look at the strace output. You'll see the server blocked in a recv() call, waiting for data from client A. The cursor just hangs on a partial recv line with no return value.

What is blocking I/O?

accept() and recv() are blocking I/O calls. When recv() has no data to read, the entire process freezes until data arrives.

While the server is blocked in recv() waiting on client A, client B's connection sits in the kernel's listen backlog. TCP accepted the connection, but your code never reaches the next accept() call to process it.

One idle connection freezes every other connection. This is the core limitation of a single-process, blocking server.

  • Press Ctrl+C in terminal 3 to detach strace.
  • Press Ctrl+C in terminal 2 to disconnect the client.
  • Press Ctrl+C in terminal 1 to stop the server.

strace showing an error?

  • If you see a "No such process" error, make sure the server is still running in terminal 1 before you run the strace command.
  • If you see a "Permission denied" error, prefix the command with sudo.
  • If nothing appears at all, the filter is working correctly. The output only updates when a recv call is made. Try typing a message in client A's terminal. You should see recv activity appear in the strace output.

Help me debug my strace command for attaching to the server process.

You just proved a fundamental limitation of your server. While it is blocked in recv() waiting on one client, every other client is locked out. Next up, you'll fix this with fork() so each client gets its own process.

Fix It with fork()

You proved it in the last step. Your server gets trapped inside recv() waiting on one client, and every other connection sits in the kernel's backlog queue untouched.

The fix is concurrency. fork() is the simplest Unix solution: the parent process stays in the accept loop, and each new client gets its own child process. In this step, you will restructure your server so every connection runs in an independent process.

In this step, get ready to:
  • Extract the echo logic into a reusable handle_client function.
  • Add fork() so each client gets its own process.
  • Handle zombie processes with a SIGCHLD signal handler.
Copy server.c and add the handle_client function

Your new server starts as a copy of the original. The copy gives you all the socket setup code you already wrote. You will restructure it to use fork() for concurrency.

  • Create server_fork.c by copying your original server:
cp server.c server_fork.c
  • Open server_fork.c in nano by running:
nano server_fork.c

The first change is adding two new headers that your forking server needs.

  • Find the #include <sys/socket.h> line near the top of the file.
  • Add a new line directly below it:
#include <sys/wait.h>
  • Find the #include <arpa/inet.h> line.
  • Add a new line directly below it:
#include <signal.h>

Next, the echo loop currently lives inside main. You need to pull it out into its own function so each forked child process can call it independently.

  • Find the #define BUFFER_SIZE 1024 line.
  • Add a blank line below it, then add the following handle_client function:
void handle_client(int client_fd, struct sockaddr_in *client_addr) {
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;

    printf("[Child %d] Handling client %s:%d\n",
           getpid(),
           inet_ntoa(client_addr->sin_addr),
           ntohs(client_addr->sin_port));

    // Echo loop: receive data and send it back
    while ((bytes_read = recv(client_fd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
        buffer[bytes_read] = '\0';
        printf("[Child %d] Received: %s", getpid(), buffer);
        send(client_fd, buffer, bytes_read, 0);
    }

    printf("[Child %d] Client disconnected\n", getpid());
    close(client_fd);
}

What does this function do?

This is the same recv/send echo loop from your original server, wrapped in a function. Each child process will call it to handle one client.

  • getpid() prints the child's process ID so you can tell which process handles which client.
  • The function takes the client socket and address as arguments instead of using variables from main.

Now you need to modify main to fork a child process after each accept() call instead of handling the client inline.

  • Inside main, find these two variable declarations:
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;
  • Delete both lines. These variables now live inside handle_client.
  • In their place, add:
    pid_t pid;
  • Now scroll down inside the while (1) loop. Find the block of code that runs after the printf("Client connected: ... line:
        while ((bytes_read = recv(client_fd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
            buffer[bytes_read] = '\0';
            printf("Received: %s", buffer);
            send(client_fd, buffer, bytes_read, 0);
        }

        printf("Client disconnected\n");
        close(client_fd);
  • Delete that entire block (the while loop, the printf, and the close). Replace it with the fork logic:
        // Fork a child process for this client
        pid = fork();
        if (pid < 0) {
            perror("fork");
            close(client_fd);
            continue;
        }

        if (pid == 0) {
            // Child: close listening socket, handle client, exit
            close(server_fd);
            handle_client(client_fd, &client_addr);
            exit(0);
        } else {
            // Parent: close client socket, loop back to accept
            close(client_fd);
        }

What does this fork logic do?

fork() creates an exact copy of the running process. Both the parent and child continue executing from the same point, but fork() returns different values to each.

  • pid == 0 means you are in the child. The child closes the listening socket (it does not need it), handles the client, then exits.
  • pid > 0 means you are in the parent. The parent closes the client socket (the child owns it now) and loops back to accept() immediately.
  • This is why the blocking problem disappears. The parent never enters recv(). It stays in the accept loop, ready for the next client.
  • Save the file with Ctrl+O, press Enter, then exit nano with Ctrl+X.
  • Compile server_fork.c by running:
gcc -g -Wall -o server_fork server_fork.c
  • If GCC prints no errors, the compile succeeded. Start the new server:
./server_fork
  • Open a second terminal and connect with your client:
cd ~/tcp-echo && ./client
  • Type a message and confirm it echoes back.
  • Leave this client connected. Open a third terminal and connect another client:
cd ~/tcp-echo && ./client
  • Type a message in the second client.

Both clients echo independently. On the server terminal, you should see different child PIDs handling different connections.

Compile errors?

  • If you see undeclared identifier 'pid', make sure you added the pid_t pid; declaration inside main and removed buffer and bytes_read.
  • If you see undeclared identifier 'bytes_read' or 'buffer', you still have the old echo loop inside main. Delete it and replace with the fork logic.
  • If you see implicit declaration of function 'handle_client', make sure the handle_client function appears above main in the file.

Help me debug a compile error in server_fork.c

  • Press Ctrl+C in both client terminals and the server terminal to stop everything.
Handle zombie processes with SIGCHLD

There is one problem left. When a child process exits, the kernel keeps a record of it in the process table until the parent calls waitpid(). Without that call, exited children pile up as zombie processes.

  • Open server_fork.c in nano again:
nano server_fork.c
  • Find the closing brace of the handle_client function. Add a blank line below it, then add the sigchld_handler function:
void sigchld_handler(int sig) {
    (void)sig;
    // Reap all terminated children without blocking
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

What does this handler do?

The kernel sends SIGCHLD to the parent whenever a child exits. This handler catches that signal and calls waitpid(-1, NULL, WNOHANG) in a loop to clean up every finished child.

WNOHANG means the call returns immediately if no children have exited yet, so the parent never blocks.

  • Now scroll down to the beginning of main. Find the line:
    pid_t pid;
  • Add the following signal registration code directly below it, before the socket() call:
    // Register SIGCHLD handler to reap zombie processes
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGCHLD, &sa, NULL);

Why SA_RESTART?

When a signal arrives, it can interrupt system calls like accept(). Without SA_RESTART, the interrupted call would fail with an error instead of resuming. SA_RESTART tells the kernel to automatically restart the interrupted call so your accept loop keeps running smoothly.

  • Save with Ctrl+O, press Enter, then exit with Ctrl+X.

✔️ Awesome, I've got everything!

Double check you have saved server_fork.c with all the changes.

ⓧ I'd like to double check the full code

Your server_fork.c should have these sections in order. Scroll through the file and confirm each one is present.

  • Includes: the original seven headers plus #include <sys/wait.h> and #include <signal.h>.
  • The #define PORT 9090 and #define BUFFER_SIZE 1024 constants.
  • The handle_client function with the echo loop, printing child PID with each message.
  • The sigchld_handler function calling waitpid(-1, NULL, WNOHANG) in a loop.
  • main with pid_t pid declared (no buffer or bytes_read), sigaction setup before socket(), and the fork logic after accept().
Compile and test with multiple clients
  • Recompile server_fork.c by running:
gcc -g -Wall -o server_fork server_fork.c
  • Start the forking server:
./server_fork

You should see Server listening on port 9090.

  • Open a second terminal. Connect the first client:
cd ~/tcp-echo && ./client
  • Type a message and confirm it echoes back with the Echo: prefix.
  • Leave this client connected and idle. Open a third terminal. Connect a second client:
cd ~/tcp-echo && ./client

Before you type anything, think about what will happen. In the last step, the second client hung. What do you expect this time?

  • Type a message in the second client.

It echoes back immediately. Both clients work at the same time. On the server terminal, you should see different child PIDs handling the two connections.

  • Disconnect one client with Ctrl+C. On the server terminal, that child reports the disconnection. The other client keeps working.

Second client still hangs?

  • Make sure you are running ./server_fork, not ./server. The original server binary still exists in the directory.
  • Check that the fork logic replaced the old echo loop entirely. If both exist in main, the server will behave unpredictably.

Help me debug my forking server

Your server now handles multiple clients simultaneously, each in its own process. You built the same concurrency pattern that real network daemons use under the hood. Up next, take on an optional Secret Mission to add command parsing and build your first protocol layer on top of TCP.

Secret mission

Add Server Commands

Your echo server repeats everything it hears. What if it could understand commands? In this mission, you will add /time, /stats, and /quit commands to build your first protocol on top of TCP.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your Kali Linux VM, so there are no ongoing cloud costs or paid services to worry about. Decide whether to keep your project files and tools, or remove them entirely.

Resources you used:

  • The ~/tcp-echo project directory (source files, compiled binaries, and the test hello.c file).
  • The build-essential package (GCC, make, and C library headers).
  • The gdb debugger package.
  • The strace system call tracer package.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with socket programming or extending your echo server.

  • Your source files and compiled binaries stay in ~/tcp-echo.
  • GCC, GDB, and strace remain installed. These are standard Linux development tools you will use in future projects.

Pause - I'll come back to this later

Make sure any running server or client processes are stopped. Your files stay on disk for when you come back.

  • If you still have a server running in a terminal, press Ctrl+C to stop it.
  • Close any extra terminal windows you opened during the project.
  • Your ~/tcp-echo directory and all installed packages remain in place.

Delete - I don't want to use this again

Remove all project files and optionally uninstall the development tools.

  • Stop any running server or client processes by pressing Ctrl+C in their terminal windows.
  • Delete the project directory by running:
rm -rf ~/tcp-echo
  • Confirm the directory is gone by running:
ls ~/tcp-echo

You should see a message saying the directory does not exist.

  • Optionally, uninstall the development tools by running:
sudo apt remove -y build-essential gdb strace

Keep GCC, GDB, and strace

These are foundational Linux development tools that you will use in many future cybersecurity and networking projects. Unless you are sure you will not need them, keeping them installed saves you from reinstalling later.

Nice Work!

Nice Work!

You just built a multi-client TCP echo server and client from scratch in C, using the same POSIX socket API that powers every network tool on a penetration tester's workbench.

You've learned how to:

  • Build a TCP echo server and client from scratch using the POSIX socket API (socket, bind, listen, accept, recv, send, and connect).
  • Inspect a sockaddr_in struct at the byte level with GDB, seeing how C structs map directly to network protocol fields.
  • Diagnose a real blocking I/O bug with strace, then fix it with fork() and a SIGCHLD signal handler so multiple clients connect simultaneously.
  • Completed an optional Secret Mission to push your skills further.

Ready to quiz yourself?