diff --git a/config.go b/config.go index 686a941b..1e5e221a 100644 --- a/config.go +++ b/config.go @@ -33,6 +33,7 @@ const ( defaultLogFilename = "btcd.log" defaultMaxPeers = 125 defaultBanDuration = time.Hour * 24 + defaultBanThreshold = 100 defaultMaxRPCClients = 10 defaultMaxRPCWebsockets = 25 defaultVerifyEnabled = false @@ -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"` @@ -324,6 +327,7 @@ func loadConfig() (*config, []string, error) { DebugLevel: defaultLogLevel, MaxPeers: defaultMaxPeers, BanDuration: defaultBanDuration, + BanThreshold: defaultBanThreshold, RPCMaxClients: defaultMaxRPCClients, RPCMaxWebsockets: defaultMaxRPCWebsockets, DataDir: defaultDataDir, diff --git a/doc.go b/doc.go index 03ab1c58..d15b4f10 100644 --- a/doc.go +++ b/doc.go @@ -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 diff --git a/dynamicbanscore.go b/dynamicbanscore.go new file mode 100644 index 00000000..c098ae95 --- /dev/null +++ b/dynamicbanscore.go @@ -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) +} diff --git a/dynamicbanscore_test.go b/dynamicbanscore_test.go new file mode 100644 index 00000000..070bc6ae --- /dev/null +++ b/dynamicbanscore_test.go @@ -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.") + } +} diff --git a/rpcserver.go b/rpcserver.go index 0be2d5fc..38463727 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -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 { diff --git a/sample-btcd.conf b/sample-btcd.conf index df2e6879..b0ab0bc0 100644 --- a/sample-btcd.conf +++ b/sample-btcd.conf @@ -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 diff --git a/server.go b/server.go index ddd8ccec..46236f7b 100644 --- a/server.go +++ b/server.go @@ -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{} @@ -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. @@ -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 @@ -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 @@ -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.