Skip to content

Commit

Permalink
Implement banning based on dynamic ban scores
Browse files Browse the repository at this point in the history
Dynamic ban scores consist of a persistent and a decaying component. The
persistent score can be used to create simple additive banning policies
simlar to those found in other bitcoin node implementations. The
decaying score enables the creation of evasive logic which handles
misbehaving peers (especially application layer DoS attacks) gracefully
by disconnecting and banning peers attempting various kinds of flooding.
Dynamic ban scores allow these two approaches to be used in tandem.

This pull request includes the following:

 - Dynamic ban score type & functions, with tests for core functionality
 - Ban score of connected peers can be queried via rpc (getpeerinfo)
 - Example policy with decaying score increments on mempool and getdata
 - Logging of misbehavior once half of the ban threshold is reached
 - Banning logic can be disabled via configuration (enabled by default)
 - User defined ban threshold can be set via configuration
  • Loading branch information
tb00 committed Feb 16, 2016
1 parent 907152c commit c75fea9
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 4 deletions.
4 changes: 4 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
defaultLogFilename = "btcd.log"
defaultMaxPeers = 125
defaultBanDuration = time.Hour * 24
defaultBanThreshold = 100
defaultMaxRPCClients = 10
defaultMaxRPCWebsockets = 25
defaultVerifyEnabled = false
Expand Down Expand Up @@ -84,7 +85,9 @@ type config struct {
DisableListen bool `long:"nolisten" description:"Disable listening for incoming connections -- NOTE: Listening is automatically disabled if the --connect or --proxy options are used without also specifying listen interfaces via --listen"`
Listeners []string `long:"listen" description:"Add an interface/port to listen for connections (default all interfaces port: 8333, testnet: 18333)"`
MaxPeers int `long:"maxpeers" description:"Max number of inbound and outbound peers"`
DisableBanning bool `long:"nobanning" description:"Disable banning of misbehaving peers"`
BanDuration time.Duration `long:"banduration" description:"How long to ban misbehaving peers. Valid time units are {s, m, h}. Minimum 1 second"`
BanThreshold uint32 `long:"banthreshold" description:"Maximum allowed ban score before disconnecting and banning misbehaving peers."`
RPCUser string `short:"u" long:"rpcuser" description:"Username for RPC connections"`
RPCPass string `short:"P" long:"rpcpass" default-mask:"-" description:"Password for RPC connections"`
RPCLimitUser string `long:"rpclimituser" description:"Username for limited RPC connections"`
Expand Down Expand Up @@ -324,6 +327,7 @@ func loadConfig() (*config, []string, error) {
DebugLevel: defaultLogLevel,
MaxPeers: defaultMaxPeers,
BanDuration: defaultBanDuration,
BanThreshold: defaultBanThreshold,
RPCMaxClients: defaultMaxRPCClients,
RPCMaxWebsockets: defaultMaxRPCWebsockets,
DataDir: defaultDataDir,
Expand Down
3 changes: 3 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Application Options:
--listen= Add an interface/port to listen for connections
(default all interfaces port: 8333, testnet: 18333)
--maxpeers= Max number of inbound and outbound peers (125)
--nobanning Disable banning of misbehaving peers
--banthreshold= Maximum allowed ban score before disconnecting and
banning misbehaving peers.
--banduration= How long to ban misbehaving peers. Valid time units
are {s, m, h}. Minimum 1 second (24h0m0s)
-u, --rpcuser= Username for RPC connections
Expand Down
146 changes: 146 additions & 0 deletions dynamicbanscore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) 2016 The btcsuite developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.

package main

import (
"fmt"
"math"
"sync"
"time"
)

const (
// Halflife defines the time (in seconds) by which the transient part
// of the ban score decays to one half of it's original value.
Halflife = 60

// lambda is the decaying constant.
lambda = math.Ln2 / Halflife

// Lifetime defines the maximum age of the transient part of the ban
// score to be considered a non-zero score (in seconds).
Lifetime = 1800

// precomputedLen defines the amount of decay factors (one per second) that
// should be precomputed at initialization.
precomputedLen = 64
)

// precomputedFactor stores precomputed exponential decay factors for the first
// 'precomputedLen' seconds starting from t == 0.
var precomputedFactor [precomputedLen]float64

// init precomputes decay factors.
func init() {
for i := range precomputedFactor {
precomputedFactor[i] = math.Exp(-1.0 * float64(i) * lambda)
}
}

// decayFactor returns the decay factor at t seconds, using precalculated values
// if available, or calculating the factor if needed.
func decayFactor(t int64) float64 {
if t < precomputedLen {
return precomputedFactor[t]
}
return math.Exp(-1.0 * float64(t) * lambda)
}

// dynamicBanScore provides dynamic ban scores consisting of a persistent and a
// decaying component. The persistent score could be utilized to create simple
// additive banning policies similar to those found in other bitcoin node
// implementations.
//
// The decaying score enables the creation of evasive logic which handles
// misbehaving peers (especially application layer DoS attacks) gracefully
// by disconnecting and banning peers attempting various kinds of flooding.
// dynamicBanScore allows these two approaches to be used in tandem.
//
// Zero value: Values of type dynamicBanScore are immediately ready for use upon
// declaration.
type dynamicBanScore struct {
lastUnix int64
transient float64
persistent uint32
sync.Mutex
}

// String returns the ban score as a human-readable string.
func (s *dynamicBanScore) String() string {
s.Lock()
r := fmt.Sprintf("persistent %v + transient %v at %v = %v as of now",
s.persistent, s.transient, s.lastUnix, s.Int())
s.Unlock()
return r
}

// Int returns the current ban score, the sum of the persistent and decaying
// scores.
//
// This function is safe for concurrent access.
func (s *dynamicBanScore) Int() uint32 {
s.Lock()
r := s.int(time.Now())
s.Unlock()
return r
}

// Increase increases both the persistent and decaying scores by the values
// passed as parameters. The resulting score is returned.
//
// This function is safe for concurrent access.
func (s *dynamicBanScore) Increase(persistent, transient uint32) uint32 {
s.Lock()
r := s.increase(persistent, transient, time.Now())
s.Unlock()
return r
}

// Reset set both persistent and decaying scores to zero.
//
// This function is safe for concurrent access.
func (s *dynamicBanScore) Reset() {
s.Lock()
s.persistent = 0
s.transient = 0
s.lastUnix = 0
s.Unlock()
}

// int returns the ban score, the sum of the persistent and decaying scores at a
// given point in time.
//
// This function is not safe for concurrent access. It is intended to be used
// internally and during testing.
func (s *dynamicBanScore) int(t time.Time) uint32 {
dt := t.Unix() - s.lastUnix
if s.transient < 1 || dt < 0 || Lifetime < dt {
return s.persistent
}
return s.persistent + uint32(s.transient*decayFactor(dt))
}

// increase increases the persistent, the decaying or both scores by the values
// passed as parameters. The resulting score is calculated as if the action was
// carried out at the point time represented by the third paramter. The
// resulting score is returned.
//
// This function is not safe for concurrent access.
func (s *dynamicBanScore) increase(persistent, transient uint32, t time.Time) uint32 {
s.persistent += persistent
tu := t.Unix()
dt := tu - s.lastUnix

if transient > 0 {
if Lifetime < dt {
s.transient = 0
} else if s.transient > 1 && dt > 0 {
s.transient *= decayFactor(dt)
}
s.transient += float64(transient)
s.lastUnix = tu
}
return s.persistent + uint32(s.transient)
}
68 changes: 68 additions & 0 deletions dynamicbanscore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2016 The btcsuite developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.

package main

import (
"math"
"testing"
"time"
)

// TestDynamicBanScoreDecay tests the exponential decay implemented in
// dynamicBanScore.
func TestDynamicBanScoreDecay(t *testing.T) {
var bs dynamicBanScore
base := time.Now()

r := bs.increase(100, 50, base)
if r != 150 {
t.Errorf("Unexpected result %d after ban score increase.", r)
}

r = bs.int(base.Add(time.Minute))
if r != 125 {
t.Errorf("Halflife check failed - %d instead of 125", r)
}

r = bs.int(base.Add(7 * time.Minute))
if r != 100 {
t.Errorf("Decay after 7m - %d instead of 100", r)
}
}

// TestDynamicBanScoreLifetime tests that dynamicBanScore properly yields zero
// once the maximum age is reached.
func TestDynamicBanScoreLifetime(t *testing.T) {
var bs dynamicBanScore
base := time.Now()

r := bs.increase(0, math.MaxUint32, base)
r = bs.int(base.Add(Lifetime * time.Second))
if r != 3 { // 3, not 4 due to precision loss and truncating 3.999...
t.Errorf("Pre max age check with MaxUint32 failed - %d", r)
}
r = bs.int(base.Add((Lifetime + 1) * time.Second))
if r != 0 {
t.Errorf("Zero after max age check failed - %d instead of 0", r)
}
}

// TestDynamicBanScore tests exported functions of dynamicBanScore. Exponential
// decay or other time based behavior is tested by other functions.
func TestDynamicBanScoreReset(t *testing.T) {
var bs dynamicBanScore
if bs.Int() != 0 {
t.Errorf("Initial state is not zero.")
}
bs.Increase(100, 0)
r := bs.Int()
if r != 100 {
t.Errorf("Unexpected result %d after ban score increase.", r)
}
bs.Reset()
if bs.Int() != 0 {
t.Errorf("Failed to reset ban score.")
}
}
2 changes: 1 addition & 1 deletion rpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -2382,7 +2382,7 @@ func handleGetPeerInfo(s *rpcServer, cmd interface{}, closeChan <-chan struct{})
Inbound: statsSnap.Inbound,
StartingHeight: statsSnap.StartingHeight,
CurrentHeight: statsSnap.LastBlock,
BanScore: 0,
BanScore: int32(p.banScore.Int()),
SyncNode: p == syncPeer,
}
if p.LastPingNonce() != 0 {
Expand Down
6 changes: 6 additions & 0 deletions sample-btcd.conf
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@
; Maximum number of inbound and outbound peers.
; maxpeers=125

; Disable banning of misbehaving peers.
; nobanning=1

; Maximum allowed ban score before disconnecting and banning misbehaving peers.`
; banthreshold=100

; How long to ban misbehaving peers. Valid time units are {s, m, h}.
; Minimum 1s.
; banduration=24h
Expand Down
56 changes: 53 additions & 3 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,8 @@ type serverPeer struct {
requestedBlocks map[wire.ShaHash]struct{}
filter *bloom.Filter
knownAddresses map[string]struct{}
banScore dynamicBanScore
quit chan struct{}

// The following chans are used to sync blockmanager and server.
txProcessed chan struct{}
blockProcessed chan struct{}
Expand Down Expand Up @@ -291,6 +291,40 @@ func (sp *serverPeer) pushAddrMsg(addresses []*wire.NetAddress) {
sp.addKnownAddresses(known)
}

// addBanScore increases the persistent and decaying ban score fields by the
// values passed as parameters. If the resulting score exceeds half of the ban
// threshold, a warning is logged including the reason provided. Further, if
// the score is above the ban threshold, the peer will be banned and
// disconnected.
func (sp *serverPeer) addBanScore(persistent, transient uint32, reason string) {
// No warning is logged and no score is calculated if banning is disabled.
if cfg.DisableBanning {
return
}
warnThreshold := cfg.BanThreshold >> 1
if transient == 0 && persistent == 0 {
// The score is not being increased, but a warning message is still
// logged if the score is above the warn threshold.
score := sp.banScore.Int()
if score > warnThreshold {
peerLog.Warnf("Misbehaving peer %s: %s -- ban score is %d, "+
"it was not increased this time", sp, reason, score)
}
return
}
score := sp.banScore.Increase(persistent, transient)
if score > warnThreshold {
peerLog.Warnf("Misbehaving peer %s: %s -- ban score increased to %d",
sp, reason, score)
if score > cfg.BanThreshold {
peerLog.Warnf("Misbehaving peer %s -- banning and disconnecting",
sp)
sp.server.BanPeer(sp)
sp.Disconnect()
}
}
}

// OnVersion is invoked when a peer receives a version bitcoin message
// and is used to negotiate the protocol version details as well as kick start
// the communications.
Expand Down Expand Up @@ -359,9 +393,15 @@ func (sp *serverPeer) OnVersion(p *peer.Peer, msg *wire.MsgVersion) {
// pool up to the maximum inventory allowed per message. When the peer has a
// bloom filter loaded, the contents are filtered accordingly.
func (sp *serverPeer) OnMemPool(p *peer.Peer, msg *wire.MsgMemPool) {
// A decaying ban score increase is applied to prevent flooding.
// The ban score accumulates and passes the ban threshold if a burst of
// mempool messages comes from a peer. The score decays each minute to
// half of its value.
sp.addBanScore(0, 33, "mempool")

// Generate inventory message with the available transactions in the
// transaction memory pool. Limit it to the max allowed inventory
// per message. The the NewMsgInvSizeHint function automatically limits
// per message. The NewMsgInvSizeHint function automatically limits
// the passed hint to the maximum allowed, so it's safe to pass it
// without double checking it here.
txMemPool := sp.server.txMemPool
Expand Down Expand Up @@ -461,6 +501,16 @@ func (sp *serverPeer) OnGetData(p *peer.Peer, msg *wire.MsgGetData) {
numAdded := 0
notFound := wire.NewMsgNotFound()

length := len(msg.InvList)
// A decaying ban score increase is applied to prevent exhausting resources
// with unusually large inventory queries.
// Requesting more than the maximum inventory vector length within a short
// period of time yields a score above the default ban threshold. Sustained
// bursts of small requests are not penalized as that would potentially ban
// peers performing IBD.
// This incremental score decays each minute to half of its value.
sp.addBanScore(0, uint32(length)*99/wire.MaxInvPerMsg, "getdata")

// We wait on this wait channel periodically to prevent queueing
// far more data than we can send in a reasonable time, wasting memory.
// The waiting occurs after the database fetch for the next one to
Expand All @@ -471,7 +521,7 @@ func (sp *serverPeer) OnGetData(p *peer.Peer, msg *wire.MsgGetData) {
for i, iv := range msg.InvList {
var c chan struct{}
// If this will be the last message we send.
if i == len(msg.InvList)-1 && len(notFound.InvList) == 0 {
if i == length-1 && len(notFound.InvList) == 0 {
c = doneChan
} else if (i+1)%3 == 0 {
// Buffered so as to not make the send goroutine block.
Expand Down

0 comments on commit c75fea9

Please sign in to comment.