A server that suddenly becomes slow, drops connections or shows a traffic spike may be under attack, but it may just as well be a busy backup job or a legitimate traffic peak. This tutorial shows how to tell the difference on Ubuntu 24.04 using built-in tools: ss for connections, tcpdump for packets, journalctl for SSH, and your web server logs. You will also check for signs that an attacker already got in, and learn how to block a source quickly.

Prerequisites

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
  • A non-root user with sudo privileges and a working SSH or console session.
  • UFW as the host firewall (the examples use it to block addresses).
  • The name of your public network interface. Find it with ip -br addr; the examples use eth0.

Step 1 - Getting a quick overview

Start with the symptoms. Check the load, memory and who is logged in:

uptime
free -h
w

Then look at the socket summary. A healthy web server has mostly estab and some timewait connections:

ss -s
Total: 1843
TCP:   2210 (estab 1702, closed 360, orphaned 3, timewait 355)

Compare these numbers with what is normal for your server. If you have no baseline, run the same commands at a quiet time later and keep the output. Everything in the next steps is about finding what is unusual, and "unusual" only means something relative to normal.

Step 2 - Looking for connection floods

List the remote addresses with the most established connections. With a state filter, ss omits the state column, so the peer address is the fourth field:

ss -Htn state established | awk '{print $4}' | sed -E 's/:[0-9]+$//; s/[][]//g' | sort | uniq -c | sort -rn | head
    412 203.0.113.50
     38 198.51.100.7
     12 192.0.2.14

One address holding hundreds of connections while others have a handful is a strong sign of abuse or a misbehaving client. Look up the owner of the address with whois 203.0.113.50 (install whois if needed) before blocking it: it could be your own monitoring or a partner's proxy.

A SYN flood shows up as many half-open connections in SYN-RECV:

ss -Htn state syn-recv | wc -l

A few dozen is normal on a busy server. Thousands, with a rising count between runs, indicate a SYN flood. Ubuntu enables SYN cookies by default, which keeps the server responsive under moderate floods. Confirm it:

sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_syncookies = 1

Step 3 - Measuring traffic volume

Volumetric attacks saturate the network link rather than the CPU. Read the interface counters twice, a few seconds apart, and compare:

ip -s link show dev eth0

For a live per-connection view, install iftop:

sudo apt install iftop
sudo iftop -i eth0 -nNP

-n and -N skip DNS and port name lookups (important under attack, when lookups add load), and -P shows ports. Press q to exit. If a single source or destination port dominates the bandwidth, note it for the next step.

Step 4 - Sampling packets with tcpdump

tcpdump shows what the traffic actually is. Capture a limited sample so the command stops on its own, and count packets per source address (IPv4):

sudo tcpdump -nni eth0 -c 2000 -q 'ip and not port 22' 2>/dev/null | awk '{print $3}' | cut -d. -f1-4 | sort | uniq -c | sort -rn | head

Excluding port 22 keeps your own SSH session out of the count. To see only new TCP connection attempts (SYN without ACK), which is what a SYN flood consists of:

sudo tcpdump -nni eth0 -c 50 'tcp[tcpflags] & (tcp-syn|tcp-ack) == tcp-syn'

UDP floods often target or come from ports of common amplification services such as DNS (53), NTP (123) or memcached (11211):

sudo tcpdump -nni eth0 -c 50 udp

Save a short capture if you need to share it with support or analyze it later in Wireshark:

sudo tcpdump -nni eth0 -c 10000 -w /tmp/attack.pcap

Step 5 - Detecting SSH brute force

Every server with SSH exposed receives login attempts all day. What matters is the volume and whether any of them succeeded. Count failed attempts in the last hour:

sudo journalctl -u ssh --since "1 hour ago" | grep -cE 'Failed password|Invalid user'

List the addresses sending the most attempts today:

sudo journalctl -u ssh --since today | grep -oP '(Failed password for (invalid user )?\S+|Invalid user \S+) from \K[0-9a-fA-F.:]+' | sort | uniq -c | sort -rn | head

More important is whether anyone logged in successfully. Review every accepted login and make sure you recognize each user and address:

sudo journalctl -u ssh --since "7 days ago" | grep 'Accepted'
Sep 24 09:12:44 web01 sshd[21873]: Accepted publickey for deploy from 192.0.2.14 port 51022 ssh2: ED25519 SHA256:...

You can also review the login history kept in wtmp:

last -a | head -20

If password authentication is enabled and you see thousands of failures, disabling passwords in favor of SSH keys and installing fail2ban removes almost all of this risk.

Step 6 - Detecting attacks on web applications

Application-level attacks look like legitimate traffic at the network level, so the web server log is the place to find them. For Nginx, count requests per client address:

sudo awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

See which paths are being hit the most. Scanners stand out by requesting files you do not have:

sudo awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
   8812 /wp-login.php
   2310 /xmlrpc.php
    940 /.env
    301 /

Look at requests from one suspicious address in detail:

sudo grep '^203.0.113.50 ' /var/log/nginx/access.log | tail -20

For Apache, the same commands work on /var/log/apache2/access.log. A single IP hammering wp-login.php or xmlrpc.php is a brute force against WordPress; requests for /.env, /.git/config or phpmyadmin are automated vulnerability scanners.

Step 7 - Checking for signs of compromise

Attacks that succeed tend to leave traces. Check for these even if the previous steps looked normal.

Processes using unexpected CPU are a classic sign of a cryptocurrency miner:

ps -eo pid,user,%cpu,etime,cmd --sort=-%cpu | head -15

List every listening port with the owning process, and question anything you did not install:

sudo ss -tulpn

Look for user accounts with a login shell that you did not create, and for unexpected keys in authorized_keys:

awk -F: '$7 !~ /(nologin|false)$/ {print $1, $3, $7}' /etc/passwd
sudo find / -xdev -name authorized_keys -exec ls -l {} \;

Check scheduled jobs, a common persistence method:

sudo ls -la /etc/cron.d /var/spool/cron/crontabs
systemctl list-timers --all

Finally, verify installed package files against their checksums to detect replaced binaries:

sudo apt install debsums
sudo debsums -s

No output means every file matches. Configuration files you edited yourself are not reported by -s, but modified binaries are.

Step 8 - Blocking an attacking address

For a single abusive address, insert a deny rule at the top of the UFW rule list so it is evaluated before your allow rules:

sudo ufw insert 1 deny from 203.0.113.50

For a whole range:

sudo ufw insert 1 deny from 203.0.113.0/24

Confirm the rule is in place and in first position:

sudo ufw status numbered

Manual blocks do not scale against distributed attacks. For recurring SSH or web brute force, fail2ban blocks offenders automatically based on the same log patterns you checked above. For HTTP floods, rate limit in the web server (for example, limit_req in Nginx). For attacks that exceed your link capacity, filtering must happen upstream, as noted in Step 3.

Conclusion

You checked the server at each layer: connection counts and SYN floods with ss, bandwidth with iftop, packet content with tcpdump, SSH and web logs for brute force and scans, and the system itself for signs of compromise. Keeping a baseline of normal values makes each of these checks much faster to interpret. As next steps, set up fail2ban for SSH and your web server, enable key-only SSH authentication, and configure monitoring with alerts so you notice the next spike before your users do.