Day 94 — SYN Flood Defense
| Learning objective | Detect and limit half-open TCP connections per source IP |
| Key packages | net, sync, sync/atomic, time |
| Security context | SYN floods exhaust server connection tables; defense requires limiting half-open state per IP |
Key Concepts
SYN flood mechanics:
- Attacker sends SYN packets with spoofed source IPs
- Server allocates connection state, sends SYN-ACK
- Attacker never sends ACK — server waits (default: 75s on Linux)
- Server's half-open queue fills; legitimate connections rejected
Go-side: The OS handles SYN cookies. Your Go code limits connections after Accept returns — so you're limiting fully-established connections that connected but haven't sent data yet.
Exercise — Half-Open Connection Limiter
package ddos
import (
"net"
"sync"
"sync/atomic"
"time"
)
// HalfOpenTracker tracks connections that connected but haven't sent data
type HalfOpenTracker struct {
mu sync.Mutex
perIP map[string]int // count of half-open conns per IP
maxPerIP int
drainAfter time.Duration
totalHalf atomic.Int64
totalBlocked atomic.Int64
}
func NewHalfOpenTracker(maxPerIP int, drainAfter time.Duration) *HalfOpenTracker {
return &HalfOpenTracker{
perIP: make(map[string]int),
maxPerIP: maxPerIP,
drainAfter: drainAfter,
}
}
// TrackConn returns a monitored connection.
// Call this immediately after net.Listener.Accept().
// If the IP has too many half-open conns, returns false — caller should close conn.
func (t *HalfOpenTracker) TrackConn(conn net.Conn) (net.Conn, bool) {
host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
return conn, true // can't parse — let through
}
t.mu.Lock()
count := t.perIP[host]
if count >= t.maxPerIP {
t.mu.Unlock()
t.totalBlocked.Add(1)
return conn, false
}
t.perIP[host] = count + 1
t.mu.Unlock()
t.totalHalf.Add(1)
// Wrap the connection; when data arrives, remove from half-open
return &trackedConn{
Conn: conn,
tracker: t,
ip: host,
timer: time.AfterFunc(t.drainAfter, func() { t.release(host) }),
}, true
}
func (t *HalfOpenTracker) release(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
if t.perIP[ip] > 0 {
t.perIP[ip]--
if t.perIP[ip] == 0 {
delete(t.perIP, ip)
}
}
t.totalHalf.Add(-1)
}
func (t *HalfOpenTracker) Stats() (half, blocked int64) {
return t.totalHalf.Load(), t.totalBlocked.Load()
}
type trackedConn struct {
net.Conn
tracker *HalfOpenTracker
ip string
timer *time.Timer
released sync.Once
}
// Read — first data received, connection is no longer half-open
func (tc *trackedConn) Read(b []byte) (int, error) {
tc.released.Do(func() {
tc.timer.Stop()
tc.tracker.release(tc.ip)
})
return tc.Conn.Read(b)
}
func (tc *trackedConn) Close() error {
tc.released.Do(func() {
tc.timer.Stop()
tc.tracker.release(tc.ip)
})
return tc.Conn.Close()
}
// ProtectedListener wraps net.Listener with SYN flood defense
type ProtectedListener struct {
net.Listener
tracker *HalfOpenTracker
}
func NewProtectedListener(l net.Listener, maxHalfOpenPerIP int) *ProtectedListener {
return &ProtectedListener{
Listener: l,
tracker: NewHalfOpenTracker(maxHalfOpenPerIP, 3*time.Second),
}
}
func (pl *ProtectedListener) Accept() (net.Conn, error) {
for {
conn, err := pl.Listener.Accept()
if err != nil {
return nil, err
}
tracked, ok := pl.tracker.TrackConn(conn)
if !ok {
conn.Close()
continue // silently drop — don't return error
}
return tracked, nil
}
}