Skip to content

Commit 3cd5e39

Browse files
authored
Merge branch 'stable' into ext-encoding
2 parents ae0a0f6 + 7655018 commit 3cd5e39

File tree

5 files changed

+125
-9
lines changed

5 files changed

+125
-9
lines changed

src/protocol/OpenConnectionReply1.php

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

2727
public int $serverID;
28-
public bool $serverSecurity = false;
28+
public ?int $cookie = null;
2929
public int $mtuSize;
3030

31-
public static function create(int $serverId, bool $serverSecurity, int $mtuSize) : self{
31+
public static function create(int $serverId, ?int $cookie, int $mtuSize) : self{
3232
$result = new self;
3333
$result->serverID = $serverId;
34-
$result->serverSecurity = $serverSecurity;
34+
$result->cookie = $cookie;
3535
$result->mtuSize = $mtuSize;
3636
return $result;
3737
}
3838

3939
protected function encodePayload(ByteBufferWriter $out) : void{
4040
$this->writeMagic($out);
4141
BE::writeUnsignedLong($out, $this->serverID);
42-
Byte::writeUnsigned($out, $this->serverSecurity ? 1 : 0);
42+
if($this->cookie !== null){
43+
Byte::writeUnsigned($out, 1);
44+
BE::writeUnsignedInt($out, $this->cookie);
45+
//TODO: If the client supports libcat security, we're expected to send a public key here.
46+
//However this would require context-specific logic and I really cba with it
47+
}else{
48+
Byte::writeUnsigned($out, 0);
49+
}
4350
BE::writeUnsignedShort($out, $this->mtuSize);
4451
}
4552

4653
protected function decodePayload(ByteBufferReader $in) : void{
4754
$this->readMagic($in);
4855
$this->serverID = BE::readUnsignedLong($in);
49-
$this->serverSecurity = Byte::readUnsigned($in) !== 0;
56+
if(Byte::readUnsigned($in) !== 0){
57+
$this->cookie = BE::readUnsignedInt($in);
58+
//TODO: If the server supports libcat security, it'll send a public key here.
59+
}else{
60+
$this->cookie = null;
61+
}
5062
$this->mtuSize = BE::readUnsignedShort($in);
5163
}
5264
}

src/protocol/OpenConnectionRequest2.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,44 @@
1717
namespace raklib\protocol;
1818

1919
use pmmp\encoding\BE;
20+
use pmmp\encoding\Byte;
2021
use pmmp\encoding\ByteBufferReader;
2122
use pmmp\encoding\ByteBufferWriter;
2223
use raklib\utils\InternetAddress;
2324

2425
class OpenConnectionRequest2 extends OfflineMessage{
2526
public static $ID = MessageIdentifiers::ID_OPEN_CONNECTION_REQUEST_2;
2627

28+
private const TAIL_FIELDS_SIZE_COMMON = 2 + 8; //mtu + client ID
29+
private const TAIL_FIELDS_SIZE_IPV4 = self::TAIL_FIELDS_SIZE_COMMON + PacketSerializer::IPV4_SIZE;
30+
private const TAIL_FIELDS_SIZE_IPV6 = self::TAIL_FIELDS_SIZE_COMMON + PacketSerializer::IPV6_SIZE;
31+
2732
public int $clientID;
2833
public InternetAddress $serverAddress;
34+
public ?int $cookie = null;
2935
public int $mtuSize;
3036

3137
protected function encodePayload(ByteBufferWriter $out) : void{
3238
$this->writeMagic($out);
39+
if($this->cookie !== null){
40+
BE::writeUnsignedInt($out, $this->cookie);
41+
Byte::writeUnsigned($out, 0); //TODO: encryption challenge - not supported for now because RakNet sucks and we don't need it
42+
}
3343
PacketSerializer::putAddress($out, $this->serverAddress);
3444
BE::writeUnsignedShort($out, $this->mtuSize);
3545
BE::writeUnsignedLong($out, $this->clientID);
3646
}
3747

3848
protected function decodePayload(ByteBufferReader $in) : void{
3949
$this->readMagic($in);
50+
51+
$remaining = $in->getUnreadLength();
52+
if($remaining !== self::TAIL_FIELDS_SIZE_IPV4 && $remaining !== self::TAIL_FIELDS_SIZE_IPV6){
53+
$this->cookie = BE::readUnsignedInt($in);
54+
//TODO: encryption challenge - not supported for now because RakNet sucks and we don't need it
55+
//we could handle this by looking at the remaining length of the packet, but it's not worth the complexity
56+
Byte::readUnsigned($in);
57+
}
4058
$this->serverAddress = PacketSerializer::getAddress($in);
4159
$this->mtuSize = BE::readUnsignedShort($in);
4260
$this->clientID = BE::readUnsignedLong($in);

src/protocol/PacketSerializer.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ private function __construct(){
3636
//NOOP
3737
}
3838

39+
public const IPV4_SIZE =
40+
1 + //type
41+
4 + //ip
42+
2; //port
43+
public const IPV6_SIZE =
44+
1 + //type
45+
2 + //family
46+
2 + //port
47+
4 + //flow info
48+
16 + //ip
49+
4; //scope ID
50+
3951
/**
4052
* @throws DataDecodeException
4153
*/

src/server/Server.php

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

8686
protected int $nextSessionId = 0;
8787

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

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

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

221233
++$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 pmmp\encoding\BE;
1920
use pmmp\encoding\ByteBufferReader;
2021
use pmmp\encoding\DataDecodeException;
2122
use raklib\generic\Session;
@@ -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 BE::packUnsignedLong(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() . BE::packUnsignedShort($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)