Post

Self-Auditing Your VPS with an External Pentest Script

Self-Auditing Your VPS with an External Pentest Script

This post presents a bash script that audits a VPS from an external perspective: port scanning, TLS configuration, security headers, path traversal, API authentication, and information leakage. The script runs from an external machine to observe the same attack surface visible to malicious actors.

Problem Statement

After hardening a server (see previous post), verification from an external perspective is necessary to catch:

  • Ports accidentally exposed through firewall misconfigurations
  • TLS accepting deprecated protocols
  • Security headers missing in production
  • Path traversal bypasses in nginx
  • Information leaking in error responses

An external audit provides the same perspective an attacker would have.

Proposed Solution

A single bash script executed from any external machine (laptop, different VPS, CI/CD) that:

  1. Scans ports and flags unexpected ones
  2. Tests TLS protocol versions and certificate expiry
  3. Checks HTTP security headers
  4. Verifies decoy responses work (for rathole setups)
  5. Attempts path traversal and injection
  6. Tests API authentication
  7. Detects information leakage
  8. Fuzzes HTTP methods and malformed requests

Implementation

Create vps-pentest.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/bin/bash
# External security audit script
# Run from an off-network machine: ./vps-pentest.sh <target> [domain]
set -uo pipefail

RED='\033[0;31m'
GRN='\033[0;32m'
YLW='\033[0;33m'
RST='\033[0m'

PASS=0
FAIL=0
WARN=0

TARGET="${1:-}"
DOMAIN="${2:-$TARGET}"

if [[ -z "$TARGET" ]]; then
    echo "Usage: vps-pentest.sh <ip-or-hostname> [domain]"
    echo "  Run from an external machine to audit the target."
    exit 1
fi

pass() { ((PASS++)); printf "${GRN}[PASS]${RST} %s\n" "$1"; }
fail() { ((FAIL++)); printf "${RED}[FAIL]${RST} %s\n" "$1"; }
warn() { ((WARN++)); printf "${YLW}[WARN]${RST} %s\n" "$1"; }
info() { printf "      %s\n" "$1"; }
section() { echo ""; echo "======================================"; echo "  $1"; echo "======================================"; }

# Check dependencies
for cmd in curl openssl nmap timeout nc; do
    if ! command -v "$cmd" &>/dev/null; then
        echo "ERROR: $cmd is required but not installed"
        exit 1
    fi
done

echo "Starting security audit of $TARGET"
echo "Domain: $DOMAIN"
echo "Time: $(date)"

Section 1: Port Scanning

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
section "PORT SCAN"

# Define expected open ports - customize for specific setup
EXPECTED_OPEN="22 80 443"

# Ports that should be localhost-only (rathole, internal services)
LOCALHOST_ONLY="2222 2223 8443 11434"

# Common ports to scan
SCAN_PORTS="21,22,23,25,53,80,111,135,139,443,445,993,995,1433,1521,2222,2223,3306,3389,5432,5900,6379,8080,8443,8888,9090,9200,11434,27017"

OPEN_PORTS=$(nmap -Pn -p "$SCAN_PORTS" --open -T4 "$TARGET" 2>/dev/null | grep '^[0-9]' | cut -d'/' -f1)

for port in $OPEN_PORTS; do
    if echo "$EXPECTED_OPEN" | grep -qw "$port"; then
        pass "Port $port open (expected)"
    else
        fail "Unexpected port $port is open"
    fi
done

# Verify internal ports are NOT reachable
for port in $LOCALHOST_ONLY; do
    if echo "$OPEN_PORTS" | grep -qw "$port"; then
        fail "Port $port should be localhost-only but is reachable!"
    else
        pass "Port $port not reachable externally (good)"
    fi
done

Section 2: TLS Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
section "TLS CONFIGURATION"

# Check deprecated TLS versions are rejected
for ver in ssl3 tls1 tls1_1; do
    if timeout 5 openssl s_client -connect "$TARGET:443" -"$ver" </dev/null 2>&1 | grep -q "Cipher is"; then
        fail "Deprecated $ver is accepted"
    else
        pass "$ver rejected"
    fi
done

# Check modern TLS versions work
for ver in tls1_2 tls1_3; do
    if timeout 5 openssl s_client -connect "$TARGET:443" -"$ver" </dev/null 2>&1 | grep -q "Cipher is"; then
        pass "$ver supported"
    else
        warn "$ver not supported"
    fi
done

# Check certificate
CERT_INFO=$(timeout 5 openssl s_client -connect "$TARGET:443" -servername "$DOMAIN" </dev/null 2>/dev/null)
CERT_CN=$(echo "$CERT_INFO" | openssl x509 -noout -subject 2>/dev/null | grep -oP 'CN\s*=\s*\K.*')
CERT_EXPIRY=$(echo "$CERT_INFO" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)

if [[ -n "$CERT_CN" ]]; then
    pass "TLS certificate present (CN=$CERT_CN)"
else
    fail "Could not retrieve TLS certificate"
fi

if [[ -n "$CERT_EXPIRY" ]]; then
    # Calculate days until expiry
    EXPIRY_EPOCH=$(date -d "$CERT_EXPIRY" +%s 2>/dev/null || \
                   date -j -f "%b %d %T %Y %Z" "$CERT_EXPIRY" +%s 2>/dev/null)
    NOW_EPOCH=$(date +%s)
    DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

    if [[ "$DAYS_LEFT" -lt 7 ]]; then
        fail "Certificate expires in $DAYS_LEFT days!"
    elif [[ "$DAYS_LEFT" -lt 30 ]]; then
        warn "Certificate expires in $DAYS_LEFT days"
    else
        pass "Certificate valid for $DAYS_LEFT more days"
    fi
fi

Section 3: HTTP Security Headers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
section "HTTP SECURITY HEADERS"

HEADERS=$(curl -sk -I "https://$TARGET/" 2>/dev/null)

check_header() {
    local name="$1"
    local expected="$2"
    local val
    val=$(echo "$HEADERS" | grep -i "^$name:" | head -1)

    if [[ -z "$val" ]]; then
        fail "Missing header: $name"
    elif [[ -n "$expected" ]] && ! echo "$val" | grep -qi "$expected"; then
        warn "$name present but unexpected value"
        info "Got: $(echo "$val" | tr -d '\r')"
    else
        pass "$name present"
    fi
}

check_header "Strict-Transport-Security" "max-age"
check_header "X-Frame-Options" ""
check_header "X-Content-Type-Options" "nosniff"

# Server header should not reveal version
SERVER_HDR=$(echo "$HEADERS" | grep -i "^server:" | tr -d '\r')
if echo "$SERVER_HDR" | grep -qiE "nginx/|apache/|iis/"; then
    fail "Server header leaks version: $SERVER_HDR"
elif [[ -n "$SERVER_HDR" ]]; then
    warn "Server header present: $SERVER_HDR"
else
    pass "No server version disclosed"
fi

Section 4: Decoy Responses (for Rathole/Tunnel Setups)

For rathole configurations with nginx decoys (see rathole post), verify proper operation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
section "DECOY / PROXY SHIELDING"

# Regular GET should return decoy, not internal service
DECOY=$(curl -sk "https://$TARGET/" 2>/dev/null)
if echo "$DECOY" | grep -qE '"status":\s*"(ok|healthy)"'; then
    pass "Root path returns decoy JSON"
else
    warn "Root path response may not be decoy"
    info "Got: $(echo "$DECOY" | head -c 100)"
fi

# WebSocket upgrade should reach proxy (not decoy)
WS_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
    -H "Upgrade: websocket" -H "Connection: upgrade" \
    "https://$TARGET/" 2>/dev/null)

if [[ "$WS_CODE" == "502" || "$WS_CODE" == "101" ]]; then
    pass "WebSocket upgrade proxied correctly (HTTP $WS_CODE)"
else
    warn "WebSocket upgrade returned HTTP $WS_CODE"
fi

# Non-websocket Upgrade header should NOT be proxied
BOGUS=$(curl -sk -H "Upgrade: bogus" -H "Connection: upgrade" \
    "https://$TARGET/" 2>/dev/null)

if echo "$BOGUS" | grep -qE '"status":\s*"(ok|healthy)"'; then
    pass "Non-websocket Upgrade gets decoy (not proxied)"
else
    fail "Non-websocket Upgrade may have bypassed filter"
fi

Section 5: Path Traversal Testing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
section "PATH TRAVERSAL & INJECTION"

TRAVERSAL_PATHS=(
    "/../../../etc/passwd"
    "/..%2f..%2f..%2fetc%2fpasswd"
    "/%2e%2e/%2e%2e/etc/passwd"
    "/api/../../../etc/passwd"
    "/api%2F..%2F..%2Fetc%2Fpasswd"
    "/%00"
    "/api/v1/config%00"
)

for path in "${TRAVERSAL_PATHS[@]}"; do
    RESP=$(curl -sk "https://$TARGET$path" 2>/dev/null)
    if echo "$RESP" | grep -q "root:"; then
        fail "Path traversal succeeded: $path"
    else
        pass "Traversal blocked: $path"
    fi
done

Section 6: API Authentication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
section "API ENDPOINT SECURITY"

# Test an authenticated endpoint (customize path for specific setup)
API_PATH="/api/v1/config"

# Without API key
RESP_NOKEY=$(curl -sk "https://$TARGET$API_PATH" 2>/dev/null)
if echo "$RESP_NOKEY" | grep -qiE '"(forbidden|unauthorized|error)"'; then
    pass "$API_PATH rejects missing API key"
elif [[ $(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET$API_PATH") == "403" ]]; then
    pass "$API_PATH returns 403 without key"
else
    fail "$API_PATH accessible without authentication"
    info "Got: $(echo "$RESP_NOKEY" | head -c 100)"
fi

# With wrong API key
RESP_BADKEY=$(curl -sk -H "X-Api-Key: invalid-key-here" \
    "https://$TARGET$API_PATH" 2>/dev/null)

if echo "$RESP_BADKEY" | grep -qiE '"(forbidden|unauthorized|error)"'; then
    pass "$API_PATH rejects invalid API key"
else
    fail "$API_PATH may accept invalid key"
fi

# Key in query string (should NOT work - keys belong in headers)
RESP_QSKEY=$(curl -sk "https://$TARGET$API_PATH?api_key=test" 2>/dev/null)
if echo "$RESP_QSKEY" | grep -qiE '"(forbidden|unauthorized|error)"'; then
    pass "$API_PATH rejects key in query string"
else
    warn "$API_PATH may accept key via query string"
fi

Section 7: Information Leakage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
section "INFORMATION LEAKAGE"

# Check responses do not contain HTML with server info
for path in "/" "/health" "/nonexistent" "/api/v1/status"; do
    RESP=$(curl -sk "https://$TARGET$path" 2>/dev/null)
    if echo "$RESP" | grep -qiE '<html|<head|powered.by|nginx|apache'; then
        fail "$path leaks HTML/server info"
        info "Sample: $(echo "$RESP" | head -c 100)"
    else
        pass "$path returns clean response"
    fi
done

# Check error responses do not leak stack traces
for method in DELETE PUT PATCH; do
    RESP=$(curl -sk -X "$method" "https://$TARGET/" 2>/dev/null)
    if echo "$RESP" | grep -qiE 'traceback|exception|stack|at line|error in'; then
        fail "$method request leaks stack trace"
    else
        pass "$method request: no stack trace"
    fi
done

Section 8: SSH Hardening Check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
section "SSH HARDENING"

# Check if SSH prompts for password (should be key-only)
SSH_OUTPUT=$(timeout 5 ssh -o BatchMode=yes \
    -o StrictHostKeyChecking=no \
    -o ConnectTimeout=5 \
    "$TARGET" exit 2>&1 || true)

if echo "$SSH_OUTPUT" | grep -qi "password"; then
    warn "SSH may accept password authentication"
else
    pass "SSH does not prompt for password (key-only)"
fi

# Get SSH banner
SSH_BANNER=$(timeout 3 bash -c "echo '' | nc -w1 $TARGET 22 2>/dev/null" | head -1)
if [[ -n "$SSH_BANNER" ]]; then
    info "SSH banner: $SSH_BANNER"
fi

Section 9: HTTP Method Fuzzing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
section "HTTP METHOD FUZZING"

for method in TRACE OPTIONS CONNECT PROPFIND MKCOL; do
    CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
        -X "$method" "https://$TARGET/" 2>/dev/null)

    case "$CODE" in
        405|404|403|400|200)
            pass "$method returns HTTP $CODE (handled)"
            ;;
        500|502|503)
            warn "$method returns HTTP $CODE (server error)"
            ;;
        *)
            warn "$method returned unexpected HTTP $CODE"
            ;;
    esac
done

Section 10: Malformed Requests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
section "MALFORMED REQUESTS"

# Oversized header (should be rejected)
JUNK=$(head -c 16000 /dev/urandom | base64 | tr -d '\n')
CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
    -H "X-Junk: $JUNK" "https://$TARGET/" 2>/dev/null)

if [[ "$CODE" == "494" || "$CODE" == "431" || "$CODE" == "400" ]]; then
    pass "Oversized header rejected (HTTP $CODE)"
elif [[ "$CODE" == "200" ]]; then
    pass "Oversized header handled gracefully"
else
    warn "Oversized header returned HTTP $CODE"
fi

# Null byte in URL
CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
    "https://$TARGET/%00" 2>/dev/null)

if [[ "$CODE" == "400" || "$CODE" == "404" ]]; then
    pass "Null byte in URL rejected (HTTP $CODE)"
else
    warn "Null byte in URL returned HTTP $CODE"
fi

# Very long URL
LONG_PATH=$(python3 -c "print('a'*8000)" 2>/dev/null || printf 'a%.0s' {1..8000})
CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
    "https://$TARGET/$LONG_PATH" 2>/dev/null)

if [[ "$CODE" == "414" || "$CODE" == "400" || "$CODE" == "404" ]]; then
    pass "Long URL handled (HTTP $CODE)"
else
    warn "Long URL returned HTTP $CODE"
fi

Results Summary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
section "RESULTS"
echo ""
printf "${GRN}PASS: %d${RST}  ${RED}FAIL: %d${RST}  ${YLW}WARN: %d${RST}\n" "$PASS" "$FAIL" "$WARN"
echo ""

if [[ "$FAIL" -gt 0 ]]; then
    echo "!! Failures detected - review above !!"
    exit 1
elif [[ "$WARN" -gt 0 ]]; then
    echo "Warnings present - review recommended."
    exit 0
else
    echo "All checks passed."
    exit 0
fi

Usage

Run from any machine that is NOT the target server:

1
2
3
4
5
# Basic usage
./vps-pentest.sh your-server.com

# With explicit domain for TLS SNI
./vps-pentest.sh 123.45.67.89 your-server.com

Sample Output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Starting security audit of your-server.com
Domain: your-server.com
Time: Sat Feb 28 15:00:00 MST 2026

======================================
  PORT SCAN
======================================
[PASS] Port 22 open (expected)
[PASS] Port 80 open (expected)
[PASS] Port 443 open (expected)
[PASS] Port 2222 not reachable externally (good)
[PASS] Port 8443 not reachable externally (good)
[PASS] Port 11434 not reachable externally (good)

======================================
  TLS CONFIGURATION
======================================
[PASS] ssl3 rejected
[PASS] tls1 rejected
[PASS] tls1_1 rejected
[PASS] tls1_2 supported
[PASS] tls1_3 supported
[PASS] TLS certificate present (CN=your-server.com)
[PASS] Certificate valid for 72 more days

======================================
  HTTP SECURITY HEADERS
======================================
[PASS] Strict-Transport-Security present
[PASS] X-Frame-Options present
[PASS] X-Content-Type-Options present
[PASS] No server version disclosed

...

======================================
  RESULTS
======================================

PASS: 42  FAIL: 0  WARN: 2

Warnings present - review recommended.

Customization

Adding Expected Ports

Edit the EXPECTED_OPEN variable:

1
2
# Web server + game server + custom app
EXPECTED_OPEN="22 80 443 27015 8080"

Adding Protected Endpoints

Add more API paths to test:

1
2
3
for path in "/api/v1/config" "/admin" "/metrics" "/.env"; do
    # ... authentication tests
done

Custom Decoy Detection

If the decoy returns different JSON:

1
2
3
if echo "$DECOY" | grep -q '"your-decoy-field"'; then
    pass "Decoy working"
fi

CI/CD Integration

Run as a scheduled job after deployments:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# .github/workflows/security-audit.yml
name: Security Audit

on:
  schedule:
    - cron: '0 6 * * *'  # Daily at 6 AM
  workflow_dispatch:

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: sudo apt-get install -y nmap netcat-openbsd

      - name: Run security audit
        run: ./vps-pentest.sh $
        env:
          VPS_HOST: $

      - name: Notify on failure
        if: failure()
        run: |
          curl -X POST "$" \
            -d '{"text":"Security audit failed for VPS!"}'

Test Coverage

TestVulnerabilities Detected
Port scanFirewall misconfigs, accidental exposures
TLS versionsOutdated protocol support
Certificate expiryLet’s Encrypt renewal failures
Security headersMissing HSTS, clickjacking protection
Decoy responsesRathole/tunnel bypass
Path traversalnginx alias vulnerabilities
API authMissing authentication checks
Info leakageError pages revealing internals
Method fuzzingUnexpected method handling
Malformed requestsInput validation gaps

Limitations

This script provides a quick sanity check, not a replacement for professional penetration testing. It does not cover:

  • Application-layer vulnerabilities (XSS, SQL injection)
  • Business logic flaws
  • Authenticated attack surfaces
  • Rate limiting / DoS resilience
  • DNS configuration

For comprehensive testing, dedicated tools (Burp Suite, OWASP ZAP) or professional security assessments are recommended.

Conclusion

Regular external audits detect configuration drift before attackers do. This script provides:

  • Rapid feedback: Execution completes in under a minute
  • Actionable results: PASS/FAIL/WARN with clear messages
  • CI/CD ready: Exit codes for automation
  • Customizable: Additional checks are easily added

The script should run after every deployment, weekly via cron, or as part of regular maintenance. The most effective security audit is one that executes consistently.

This post is licensed under CC BY 4.0 by the author.