diff --git a/resources/pocketmine.yml b/resources/pocketmine.yml index 5319249731..c7d3606020 100644 --- a/resources/pocketmine.yml +++ b/resources/pocketmine.yml @@ -83,6 +83,29 @@ network: #DO NOT DISABLE THIS unless you understand the risks involved. enable-encryption: true + #EXPERIMENTAL! Limit packet read operations per network session. + #This is intended to stop exploitation of packets with arrays in them. + #Note that enabling this system may cause players to be unexpectedly kicked, or it may fail to stop attackers. + #As of March 2025, the system is still in development and subject to change. + packet-read-ops-limit: + #What to do when a session's read ops budget is depleted. + #Supported actions are "none", "warn" and "kick". + deplete-action: warn + + #How many backlog ticks to budget for. 200 allows for a 10-second network lag spike, or a small number of complex + #packets. + session-budget-ticks: 100 + + #How much to top up each session's read operations budget per tick. Recommended to set this to about 2x the + #average number of read operations per tick per session. + #Exceeding this value won't cause any action to be taken. However, consistently exceeding it will cause the + #session's budget to be depleted, resulting in action being taken. + session-budget-per-tick: 250 + + #Whether to collect stats for debugging. This might impact performance. + #See NetworkSession::dumpDecodeCostStats() if you want to visualize the stats yourself. + collect-stats: false + debug: #If > 1, it will show debug messages in the console level: 1 diff --git a/src/YmlServerProperties.php b/src/YmlServerProperties.php index 282b0b3cdf..81568a393b 100644 --- a/src/YmlServerProperties.php +++ b/src/YmlServerProperties.php @@ -90,6 +90,11 @@ final class YmlServerProperties{ public const NETWORK_COMPRESSION_LEVEL = 'network.compression-level'; public const NETWORK_ENABLE_ENCRYPTION = 'network.enable-encryption'; public const NETWORK_MAX_MTU_SIZE = 'network.max-mtu-size'; + public const NETWORK_PACKET_READ_OPS_LIMIT = 'network.packet-read-ops-limit'; + public const NETWORK_PACKET_READ_OPS_LIMIT_COLLECT_STATS = 'network.packet-read-ops-limit.collect-stats'; + public const NETWORK_PACKET_READ_OPS_LIMIT_DEPLETE_ACTION = 'network.packet-read-ops-limit.deplete-action'; + public const NETWORK_PACKET_READ_OPS_LIMIT_SESSION_BUDGET_PER_TICK = 'network.packet-read-ops-limit.session-budget-per-tick'; + public const NETWORK_PACKET_READ_OPS_LIMIT_SESSION_BUDGET_TICKS = 'network.packet-read-ops-limit.session-budget-ticks'; public const NETWORK_UPNP_FORWARDING = 'network.upnp-forwarding'; public const PLAYER = 'player'; public const PLAYER_SAVE_PLAYER_DATA = 'player.save-player-data'; diff --git a/src/network/mcpe/NetworkSession.php b/src/network/mcpe/NetworkSession.php index 127bbe71f6..779d55d64a 100644 --- a/src/network/mcpe/NetworkSession.php +++ b/src/network/mcpe/NetworkSession.php @@ -117,13 +117,12 @@ use pocketmine\utils\Utils; use pocketmine\world\format\io\GlobalItemDataHandlers; use pocketmine\world\Position; use pocketmine\world\World; -use pocketmine\YmlServerProperties; +use pocketmine\YmlServerProperties as Yml; use function array_map; use function base64_encode; use function bin2hex; use function count; use function get_class; -use function hrtime; use function implode; use function in_array; use function is_string; @@ -140,7 +139,6 @@ use function strtolower; use function substr; use function time; use function ucfirst; -use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; use const PHP_INT_MAX; @@ -151,9 +149,17 @@ class NetworkSession{ private const INCOMING_GAME_PACKETS_PER_TICK = 2; private const INCOMING_GAME_PACKETS_BUFFER_TICKS = 100; + private const READ_OPS_PER_TICK = 100; //subject to change + private const READ_OPS_BUFFER_TICKS = 200; //subject to change + private PacketRateLimiter $packetBatchLimiter; private PacketRateLimiter $gamePacketLimiter; + private PacketRateLimiter $readOpsLimiter; + private PacketRateLimiterAction $readOpsLimiterAction; + + private bool $readOpsStats; + private \PrefixedLogger $logger; private ?Player $player = null; private ?PlayerInfo $info = null; @@ -205,24 +211,23 @@ class NetworkSession{ * @var int[] * @phpstan-var array */ - private array $packetCostsMin = []; + private array $readOpsPerPacketMin = []; /** * @var int[] * @phpstan-var array */ - private array $packetCostsMax = []; + private array $readOpsPerPacketMax = []; /** * @var int[] * @phpstan-var array */ - private array $packetCostsTotal = []; + private array $readOpsPerPacketTotal = []; /** * @var int[] * @phpstan-var array */ - private array $packetCounts = []; - private int $decodeCostTotal = 0; - private int $sessionStartHrtime; + private array $receivedPacketCounts = []; + private int $totalReadOpsUsed = 0; public function __construct( private Server $server, @@ -246,6 +251,15 @@ class NetworkSession{ $this->packetBatchLimiter = new PacketRateLimiter("Packet Batches", self::INCOMING_PACKET_BATCH_PER_TICK, self::INCOMING_PACKET_BATCH_BUFFER_TICKS); $this->gamePacketLimiter = new PacketRateLimiter("Game Packets", self::INCOMING_GAME_PACKETS_PER_TICK, self::INCOMING_GAME_PACKETS_BUFFER_TICKS); + $serverConfigGroup = $this->server->getConfigGroup(); + $this->readOpsLimiter = new PacketRateLimiter( + "Packet Read Operations", + $serverConfigGroup->getPropertyInt(Yml::NETWORK_PACKET_READ_OPS_LIMIT_SESSION_BUDGET_PER_TICK, self::READ_OPS_PER_TICK), + $serverConfigGroup->getPropertyInt(Yml::NETWORK_PACKET_READ_OPS_LIMIT_SESSION_BUDGET_TICKS, self::READ_OPS_BUFFER_TICKS), + ); + $this->readOpsLimiterAction = PacketRateLimiterAction::from($serverConfigGroup->getPropertyString(Yml::NETWORK_PACKET_READ_OPS_LIMIT_DEPLETE_ACTION, PacketRateLimiterAction::WARN->value)); + $this->readOpsStats = $serverConfigGroup->getPropertyBool(Yml::NETWORK_PACKET_READ_OPS_LIMIT_COLLECT_STATS, false); + $this->setHandler(new SessionStartPacketHandler( $this, $this->onSessionStartSuccess(...) @@ -253,8 +267,6 @@ class NetworkSession{ $this->manager->add($this); $this->logger->info($this->server->getLanguage()->translate(KnownTranslationFactory::pocketmine_network_session_open())); - - $this->sessionStartHrtime = hrtime(true); } private function getLogPrefix() : string{ @@ -482,17 +494,36 @@ class NetworkSession{ try{ $stream = PacketSerializer::decoder($buffer, 0); try{ - $stream->setReadOpsLimit(PHP_INT_MAX); //just for stats collection - $packet->decode($stream); + if($this->readOpsLimiterAction === PacketRateLimiterAction::NONE){ + $packet->decode($stream); + }else{ + $this->readOpsLimiter->update(); + $stream->setReadOpsLimit( + $this->readOpsLimiterAction === PacketRateLimiterAction::KICK ? + $this->readOpsLimiter->getBudget() : + PHP_INT_MAX //don't bail out of decoding if we're only warning + ); - $readOps = $stream->getReadOps(); + $packet->decode($stream); + $readOps = $stream->getReadOps(); + //If we exceeded the budget, a PacketDecodeException has already been thrown if we're in KICK + //mode, so we can assume we're in WARN mode here + if($this->readOpsLimiter->getBudget() < $readOps){ + $this->getLogger()->warning("Decoding " . $packet->getName() . " exceeded read ops budget! $readOps > " . $this->readOpsLimiter->getBudget()); + $this->readOpsLimiter->reset(); + }else{ + $this->readOpsLimiter->decrement($readOps); + } - $this->decodeCostTotal += $readOps; - $key = $packet->getName(); - $this->packetCostsTotal[$key] = ($this->packetCostsTotal[$key] ?? 0) + $readOps; - $this->packetCostsMin[$key] = min($this->packetCostsMin[$key] ?? PHP_INT_MAX, $readOps); - $this->packetCostsMax[$key] = max($this->packetCostsMax[$key] ?? 0, $readOps); - $this->packetCounts[$key] = ($this->packetCounts[$key] ?? 0) + 1; + if($this->readOpsStats){ + $this->totalReadOpsUsed += $readOps; + $key = $packet->getName(); + $this->readOpsPerPacketTotal[$key] = ($this->readOpsPerPacketTotal[$key] ?? 0) + $readOps; + $this->readOpsPerPacketMin[$key] = min($this->readOpsPerPacketMin[$key] ?? PHP_INT_MAX, $readOps); + $this->readOpsPerPacketMax[$key] = max($this->readOpsPerPacketMax[$key] ?? 0, $readOps); + $this->receivedPacketCounts[$key] = ($this->receivedPacketCounts[$key] ?? 0) + 1; + } + } }catch(PacketDecodeException $e){ throw PacketHandlingException::wrap($e); } @@ -530,19 +561,22 @@ class NetworkSession{ * @phpstan-return array> */ public function dumpDecodeCostStats() : array{ - $sessionTime = ((hrtime(true) - $this->sessionStartHrtime) / 1_000_000_000); + if(!$this->readOpsStats){ + throw new \LogicException("Not collecting stats for this session"); + } + $sessionTime = microtime(true) - $this->connectTime; $packetDecodeAverages = []; $packetCostsPerSecondAverages = []; - foreach(Utils::stringifyKeys($this->packetCostsTotal) as $packet => $total){ - $packetDecodeAverages[$packet] = $total / $this->packetCounts[$packet]; + foreach(Utils::stringifyKeys($this->readOpsPerPacketTotal) as $packet => $total){ + $packetDecodeAverages[$packet] = $total / $this->receivedPacketCounts[$packet]; $packetCostsPerSecondAverages[$packet] = $total / $sessionTime; } return [ - "decodeCostAvgPerSecond" => $this->decodeCostTotal / $sessionTime, - "decodeCostAvgPerPacketPerSecond" => $packetCostsPerSecondAverages, - "decodeCostAvgPerPacket" => $packetDecodeAverages, - "decodeCostMinPerPacket" => $this->packetCostsMin, - "decodeCostMaxPerPacket" => $this->packetCostsMax + "readOpsAvgPerSecondTotal" => $this->totalReadOpsUsed / $sessionTime, + "readOpsAvgPerPacketPerSecond" => $packetCostsPerSecondAverages, + "readOpsAvgPerPacket" => $packetDecodeAverages, + "readOpsMinPerPacket" => $this->readOpsPerPacketMin, + "readOpsMaxPerPacket" => $this->readOpsPerPacketMax ]; } @@ -920,7 +954,7 @@ class NetworkSession{ } $this->logger->debug("Xbox Live authenticated: " . ($this->authenticated ? "YES" : "NO")); - $checkXUID = $this->server->getConfigGroup()->getPropertyBool(YmlServerProperties::PLAYER_VERIFY_XUID, true); + $checkXUID = $this->server->getConfigGroup()->getPropertyBool(Yml::PLAYER_VERIFY_XUID, true); $myXUID = $this->info instanceof XboxLivePlayerInfo ? $this->info->getXuid() : ""; $kickForXUIDMismatch = function(string $xuid) use ($checkXUID, $myXUID) : bool{ if($checkXUID && $myXUID !== $xuid){ @@ -1409,13 +1443,5 @@ class NetworkSession{ } $this->flushSendBuffer(); - - $now = microtime(true); - if($now - $this->lastStatsReportTime > 10){ - $this->logger->debug("Decode cost stats: " . json_encode($this->dumpDecodeCostStats(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); - $this->lastStatsReportTime = $now; - } } - - private float $lastStatsReportTime = 0; } diff --git a/src/network/mcpe/PacketRateLimiter.php b/src/network/mcpe/PacketRateLimiter.php index 3f0fbf768a..cd1bce9684 100644 --- a/src/network/mcpe/PacketRateLimiter.php +++ b/src/network/mcpe/PacketRateLimiter.php @@ -62,6 +62,14 @@ final class PacketRateLimiter{ $this->budget -= $amount; } + public function getBudget() : int{ + return $this->budget; + } + + public function reset() : void{ + $this->budget = $this->maxBudget; + } + public function update() : void{ $nowNs = hrtime(true); $timeSinceLastUpdateNs = $nowNs - $this->lastUpdateTimeNs; diff --git a/src/network/mcpe/PacketRateLimiterAction.php b/src/network/mcpe/PacketRateLimiterAction.php new file mode 100644 index 0000000000..cb773a547a --- /dev/null +++ b/src/network/mcpe/PacketRateLimiterAction.php @@ -0,0 +1,30 @@ +