Skip to content

Commit 7655018

Browse files
committed
Add support for anti spoofing cookies
this is a bit nasty because RakNet's protocol is poorly designed, but it does the job. A CRC32 is computed from the remote IP, port, and a cryptographically secure random salt which is known only to the server and is rotated every 5 seconds. This is similar to the approach taken by gophertunnel. This approach is preferred over completely random cookies because we don't have to keep a map of client address -> cookie for verification this way, which means that spoofed IPs can't flood the server memory with useless cookies. This feature may be disabled by setting the rotation interval to 0 in the Server constructor. Notably, OVH has their own anti-spoofing measure that uses a fake random MTU size, and their defences are known to not work with these cookie checks as seen with other projects.
1 parent 669eb4d commit 7655018

5 files changed

Lines changed: 130 additions & 9 deletions

File tree

src/protocol/OpenConnectionReply1.php

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,40 @@ class OpenConnectionReply1 extends OfflineMessage{
2020
public static $ID = MessageIdentifiers::ID_OPEN_CONNECTION_REPLY_1;
2121

2222
public int $serverID;
23-
public bool $serverSecurity = false;
23+
public ?int $cookie = null;
2424
public int $mtuSize;
2525

26-
public static function create(int $serverId, bool $serverSecurity, int $mtuSize) : self{
26+
public static function create(int $serverId, ?int $cookie, int $mtuSize) : self{
2727
$result = new self;
2828
$result->serverID = $serverId;
29-
$result->serverSecurity = $serverSecurity;
29+
$result->cookie = $cookie;
3030
$result->mtuSize = $mtuSize;
3131
return $result;
3232
}
3333

3434
protected function encodePayload(PacketSerializer $out) : void{
3535
$this->writeMagic($out);
3636
$out->putLong($this->serverID);
37-
$out->putByte($this->serverSecurity ? 1 : 0);
37+
if($this->cookie !== null){
38+
$out->putByte(1);
39+
$out->putInt($this->cookie);
40+
//TODO: If the client supports libcat security, we're expected to send a public key here.
41+
//However this would require context-specific logic and I really cba with it
42+
}else{
43+
$out->putByte(0);
44+
}
3845
$out->putShort($this->mtuSize);
3946
}
4047

4148
protected function decodePayload(PacketSerializer $in) : void{
4249
$this->readMagic($in);
4350
$this->serverID = $in->getLong();
44-
$this->serverSecurity = $in->getByte() !== 0;
51+
if($in->getByte() !== 0){
52+
$this->cookie = $in->getInt();
53+
//TODO: If the server supports libcat security, it'll send a public key here.
54+
}else{
55+
$this->cookie = null;
56+
}
4557
$this->mtuSize = $in->getShort();
4658
}
4759
}

src/protocol/OpenConnectionRequest2.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,47 @@
1616

1717
namespace raklib\protocol;
1818

19+
use pocketmine\utils\Binary;
1920
use raklib\utils\InternetAddress;
21+
use function strlen;
2022

2123
class OpenConnectionRequest2 extends OfflineMessage{
2224
public static $ID = MessageIdentifiers::ID_OPEN_CONNECTION_REQUEST_2;
2325

26+
private const TAIL_FIELDS_SIZE_COMMON = 2 + 8; //mtu + client ID
27+
private const TAIL_FIELDS_SIZE_IPV4 = self::TAIL_FIELDS_SIZE_COMMON + PacketSerializer::IPV4_SIZE;
28+
private const TAIL_FIELDS_SIZE_IPV6 = self::TAIL_FIELDS_SIZE_COMMON + PacketSerializer::IPV6_SIZE;
29+
2430
public int $clientID;
2531
public InternetAddress $serverAddress;
32+
public ?int $cookie = null;
2633
public int $mtuSize;
2734

2835
protected function encodePayload(PacketSerializer $out) : void{
2936
$this->writeMagic($out);
37+
if($this->cookie !== null){
38+
$out->putInt($this->cookie);
39+
$out->putByte(0); //TODO: encryption challenge - not supported for now because RakNet sucks and we don't need it
40+
}
3041
$out->putAddress($this->serverAddress);
3142
$out->putShort($this->mtuSize);
3243
$out->putLong($this->clientID);
3344
}
3445

3546
protected function decodePayload(PacketSerializer $in) : void{
3647
$this->readMagic($in);
48+
49+
$remaining = strlen($in->getBuffer()) - $in->getOffset();
50+
if($remaining !== self::TAIL_FIELDS_SIZE_IPV4 && $remaining !== self::TAIL_FIELDS_SIZE_IPV6){
51+
$this->cookie = $in->getInt();
52+
if($this->cookie < 0){
53+
//BinaryStream moment
54+
$this->cookie = Binary::unsignInt($this->cookie);
55+
}
56+
//TODO: encryption challenge - not supported for now because RakNet sucks and we don't need it
57+
//we could handle this by looking at the remaining length of the packet, but it's not worth the complexity
58+
$in->getByte();
59+
}
3760
$this->serverAddress = $in->getAddress();
3861
$this->mtuSize = $in->getShort();
3962
$this->clientID = $in->getLong();

src/protocol/PacketSerializer.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@
2929

3030
final class PacketSerializer extends BinaryStream{
3131

32+
public const IPV4_SIZE =
33+
1 + //type
34+
4 + //ip
35+
2; //port
36+
public const IPV6_SIZE =
37+
1 + //type
38+
2 + //family
39+
2 + //port
40+
4 + //flow info
41+
16 + //ip
42+
4; //scope ID
43+
3244
/**
3345
* @throws BinaryDataException
3446
*/

src/server/Server.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ class Server implements ServerInterface{
8484

8585
protected int $nextSessionId = 0;
8686

87+
private int $cookieRotationIntervalTicks;
88+
8789
/**
8890
* @phpstan-param positive-int $recvMaxSplitParts
8991
* @phpstan-param positive-int $recvMaxConcurrentSplits
@@ -101,14 +103,16 @@ public function __construct(
101103
private int $recvMaxConcurrentSplits = ServerSession::DEFAULT_MAX_CONCURRENT_SPLIT_COUNT,
102104
private int $blockMessageSuppressionThreshold = self::BLOCK_MESSAGE_SUPPRESSION_THRESHOLD,
103105
private int $packetErrorSuppressionThreshold = self::PACKET_ERROR_SUPPRESSION_THRESHOLD,
104-
private bool $blockIpOnPacketErrors = true
106+
private bool $blockIpOnPacketErrors = true,
107+
int $cookieRotationIntervalSeconds = 5,
105108
){
106109
if($maxMtuSize < Session::MIN_MTU_SIZE){
107110
throw new \InvalidArgumentException("MTU size must be at least " . Session::MIN_MTU_SIZE . ", got $maxMtuSize");
108111
}
109112
$this->socket->setBlocking(false);
110113

111-
$this->unconnectedMessageHandler = new UnconnectedMessageHandler($this, $protocolAcceptor);
114+
$this->cookieRotationIntervalTicks = $cookieRotationIntervalSeconds * self::RAKLIB_TPS;
115+
$this->unconnectedMessageHandler = new UnconnectedMessageHandler($this, $protocolAcceptor, $cookieRotationIntervalSeconds > 0);
112116
}
113117

114118
public function getPort() : int{
@@ -215,6 +219,14 @@ private function tick() : void{
215219
}
216220
}
217221
}
222+
223+
if($this->cookieRotationIntervalTicks > 0 && ($this->ticks % $this->cookieRotationIntervalTicks) === 0){
224+
$mismatches = $this->unconnectedMessageHandler->getCookieMismatchSinceLastRotation();
225+
if($mismatches > 0){
226+
$this->logger->warning("Mismatched cookies detected $mismatches times since last rotation - RakLib may be experiencing an attack from spoofed IP addresses");
227+
}
228+
$this->unconnectedMessageHandler->rotateCookieSalts();
229+
}
218230
}
219231

220232
++$this->ticks;

src/server/UnconnectedMessageHandler.php

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
namespace raklib\server;
1818

19+
use pocketmine\utils\Binary;
1920
use pocketmine\utils\BinaryDataException;
2021
use raklib\generic\Session;
2122
use raklib\protocol\IncompatibleProtocolVersion;
@@ -30,9 +31,11 @@
3031
use raklib\protocol\UnconnectedPingOpenConnections;
3132
use raklib\protocol\UnconnectedPong;
3233
use raklib\utils\InternetAddress;
34+
use function crc32;
3335
use function get_class;
3436
use function min;
3537
use function ord;
38+
use function random_int;
3639
use function strlen;
3740
use function substr;
3841

@@ -43,11 +46,49 @@ class UnconnectedMessageHandler{
4346
*/
4447
private \SplFixedArray $packetPool;
4548

49+
private string $currentCookieSalt;
50+
private string $previousCookieSalt;
51+
private int $cookieMismatches = 0;
52+
4653
public function __construct(
4754
private Server $server,
48-
private ProtocolAcceptor $protocolAcceptor
55+
private ProtocolAcceptor $protocolAcceptor,
56+
private bool $antiIpSpoofCookies = true
4957
){
5058
$this->registerPackets();
59+
60+
$this->currentCookieSalt = $this->previousCookieSalt = self::newCookieSalt();
61+
}
62+
63+
private static function newCookieSalt() : string{
64+
return Binary::writeLong(random_int(PHP_INT_MIN, PHP_INT_MAX));
65+
}
66+
67+
public function rotateCookieSalts() : void{
68+
$this->previousCookieSalt = $this->currentCookieSalt;
69+
$this->currentCookieSalt = self::newCookieSalt();
70+
$this->cookieMismatches = 0;
71+
}
72+
73+
private static function calculateCookieWithSalt(InternetAddress $address, string $salt) : int{
74+
$preimage = strlen($address->getIp()) . $address->getIp() . Binary::writeShort($address->getPort()) . $salt;
75+
return crc32($preimage);
76+
}
77+
78+
/**
79+
* Calculates a cookie using the current cookie salt and the provided IP address.
80+
* Cookie salt is a server-side secret, so the client cannot guess it.
81+
*/
82+
private function calculateCookie(InternetAddress $address) : int{
83+
return self::calculateCookieWithSalt($address, $this->currentCookieSalt);
84+
}
85+
86+
/**
87+
* Returns the number of times we detected a cookie mismatch since the salt was last rotated.
88+
* May be useful for reporting spoofed IP attacks.
89+
*/
90+
public function getCookieMismatchSinceLastRotation() : int{
91+
return $this->cookieMismatches;
5192
}
5293

5394
/**
@@ -82,9 +123,30 @@ private function handle(OfflineMessage $packet, InternetAddress $address) : bool
82123
$this->server->getLogger()->notice("Refused connection from $address due to incompatible RakNet protocol version (version $packet->protocol)");
83124
}else{
84125
//IP header size (20 bytes) + UDP header size (8 bytes)
85-
$this->server->sendPacket(OpenConnectionReply1::create($this->server->getID(), false, $packet->mtuSize + 28), $address);
126+
$this->server->sendPacket(OpenConnectionReply1::create(
127+
$this->server->getID(),
128+
$this->antiIpSpoofCookies ? $this->calculateCookie($address) : null,
129+
$packet->mtuSize + 28),
130+
$address
131+
);
86132
}
87133
}elseif($packet instanceof OpenConnectionRequest2){
134+
if($this->antiIpSpoofCookies){
135+
$cookie1 = $this->calculateCookie($address);
136+
$cookie2 = self::calculateCookieWithSalt($address, $this->previousCookieSalt);
137+
if($packet->cookie !== $cookie1 && $packet->cookie !== $cookie2){
138+
$this->cookieMismatches++;
139+
//don't log this by default, we don't want to let an attacker LogDoS us
140+
//we also don't block the IP since this is probably coming from a spoofed IP
141+
//$this->server->getLogger()->debug("Not creating session for $address due to cookie mismatch (expected $cookie1 or $cookie2, but got $packet->cookie)");
142+
return true;
143+
}else{
144+
$this->server->getLogger()->debug("Cookie check succeeded for $address with cookie $packet->cookie (cookie1: $cookie1, cookie2: $cookie2)");
145+
}
146+
}else{
147+
$this->server->getLogger()->debug("No cookie check performed for $address");
148+
}
149+
88150
if($packet->serverAddress->getPort() === $this->server->getPort() or !$this->server->portChecking){
89151
if($packet->mtuSize < Session::MIN_MTU_SIZE){
90152
$this->server->getLogger()->debug("Not creating session for $address due to bad MTU size $packet->mtuSize");

0 commit comments

Comments
 (0)