Skip to content

Commit 559fc79

Browse files
authored
Merge pull request #2866 from QuentinGruber/dos-fixes
DoS Fixes
2 parents e85b0ac + 074c5d4 commit 559fc79

4 files changed

Lines changed: 148 additions & 72 deletions

File tree

‎src/servers/SoeServer/soeclient.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export default class SOEClient {
4545
outputStream: SOEOutputStream;
4646
soeClientId: string;
4747
lastKeepAliveTimer: NodeJS.Timeout | null = null;
48+
sendingInterval: NodeJS.Timeout | null = null;
4849
isDeleted: boolean = false;
4950
stats: SOEClientStats = {
5051
totalPhysicalPacketSent: 0,
@@ -77,6 +78,14 @@ export default class SOEClient {
7778
}
7879
closeTimers() {
7980
clearInterval(this._statsResetTimer as unknown as number);
81+
if (this.sendingInterval) {
82+
clearInterval(this.sendingInterval);
83+
this.sendingInterval = null;
84+
}
85+
if (this.lastKeepAliveTimer) {
86+
clearTimeout(this.lastKeepAliveTimer);
87+
this.lastKeepAliveTimer = null;
88+
}
8089
}
8190
private _resetStats() {
8291
this.stats.totalPhysicalPacketSent = 0;

‎src/servers/SoeServer/soeinputstream.ts‎

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ import {
2121
} from "../../utils/constants";
2222

2323
const debug = require("debug")("SOEInputStream");
24+
25+
// Upper bound on a reassembled payload. The announced size comes straight off
26+
// the wire, so without this a single packet can request a multi GB allocation.
27+
const MAX_REASSEMBLY_SIZE = 16 * 1024 * 1024;
28+
// Upper bound on out of order packets buffered per client.
29+
const MAX_BUFFERED_PACKETS = 1024;
30+
2431
type appData = { payload: Buffer; isFragment: boolean };
2532
export class SOEInputStream extends EventEmitter {
2633
_nextSequence: wrappedUint16 = new wrappedUint16(0);
@@ -53,8 +60,28 @@ export class SOEInputStream extends EventEmitter {
5360
// cpf == current processed fragment
5461
if (!this.has_cpf) {
5562
const firstPacket = this._appDataMap.get(firstPacketSequence) as appData; // should be always defined
63+
if (firstPacket.payload.length < DATA_HEADER_SIZE) {
64+
this._appDataMap.delete(firstPacketSequence);
65+
this.emit(
66+
"error",
67+
new Error(
68+
"processDataFragments: first fragment too small to hold a size header"
69+
)
70+
);
71+
return [];
72+
}
5673
// the total size is written has a uint32 at the first packet of a fragmented data
5774
this.cpf_totalSize = firstPacket.payload.readUInt32BE(0);
75+
if (this.cpf_totalSize > MAX_REASSEMBLY_SIZE) {
76+
this._appDataMap.delete(firstPacketSequence);
77+
this.emit(
78+
"error",
79+
new Error(
80+
`processDataFragments: announced size ${this.cpf_totalSize} exceeds the ${MAX_REASSEMBLY_SIZE} limit`
81+
)
82+
);
83+
return [];
84+
}
5885
this.cpf_dataSize = 0;
5986

6087
this.cpf_dataWithoutHeader = Buffer.allocUnsafe(this.cpf_totalSize);
@@ -82,6 +109,7 @@ export class SOEInputStream extends EventEmitter {
82109
this.cpf_dataSize += fragmentDataLen;
83110

84111
if (this.cpf_dataSize > this.cpf_totalSize) {
112+
this.has_cpf = false;
85113
this.emit(
86114
"error",
87115
new Error(
@@ -96,6 +124,7 @@ export class SOEInputStream extends EventEmitter {
96124
")"
97125
)
98126
);
127+
return [];
99128
}
100129
if (this.cpf_dataSize === this.cpf_totalSize) {
101130
// Delete all the processed fragments from memory
@@ -119,23 +148,21 @@ export class SOEInputStream extends EventEmitter {
119148
}
120149

121150
private _processData(): void {
122-
const nextFragmentSequence =
123-
(this._lastProcessedSequence + 1) & MAX_SEQUENCE;
124-
const dataToProcess = this._appDataMap.get(nextFragmentSequence);
125-
if (dataToProcess) {
126-
let appData: Array<Buffer> = [];
127-
128-
if (dataToProcess.isFragment) {
129-
appData = this.processFragmentedData(nextFragmentSequence);
130-
} else {
131-
appData = this.processSingleData(dataToProcess, nextFragmentSequence);
132-
}
133-
if (appData.length) {
134-
this.processAppData(appData);
135-
// In case there is more data to process
136-
// It can happen when packets are received out of order
137-
this._processData();
138-
}
151+
// Iterative rather than recursive: a client can fill _appDataMap with out
152+
// of order packets and then send the missing one, which would otherwise
153+
// recurse once per buffered packet and blow the stack.
154+
for (;;) {
155+
const nextFragmentSequence =
156+
(this._lastProcessedSequence + 1) & MAX_SEQUENCE;
157+
const dataToProcess = this._appDataMap.get(nextFragmentSequence);
158+
if (!dataToProcess) return;
159+
160+
const appData: Array<Buffer> = dataToProcess.isFragment
161+
? this.processFragmentedData(nextFragmentSequence)
162+
: this.processSingleData(dataToProcess, nextFragmentSequence);
163+
164+
if (!appData.length) return;
165+
this.processAppData(appData);
139166
}
140167
}
141168

@@ -189,6 +216,13 @@ export class SOEInputStream extends EventEmitter {
189216
" fragment=" + isFragment + ", lastAck: " + this._lastAck.get()
190217
);
191218
if (sequence >= this._nextSequence.get()) {
219+
if (
220+
!this._appDataMap.has(sequence) &&
221+
this._appDataMap.size >= MAX_BUFFERED_PACKETS
222+
) {
223+
// Too many out of order packets buffered, drop rather than grow forever
224+
return;
225+
}
192226
this._appDataMap.set(sequence, { payload: data, isFragment: isFragment });
193227
const wasInOrder = this.acknowledgeInputData(sequence);
194228
if (wasInOrder) {

‎src/servers/SoeServer/soeoutputstream.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ export class SOEOutputStream extends EventEmitter {
9696
}
9797

9898
writeReliable(data: Uint8Array): void {
99+
if (this._fragmentSize < 1) {
100+
// Would make the fragmentation loop below never terminate
101+
throw new Error(
102+
`Cannot fragment data with fragment size ${this._fragmentSize}`
103+
);
104+
}
99105
if (data.length <= this._fragmentSize) {
100106
this._reliable_sequence.increment();
101107
this.addToCache(this._reliable_sequence.get(), data, false);
@@ -156,16 +162,17 @@ export class SOEOutputStream extends EventEmitter {
156162

157163
ack(sequence: number, unAckData: Map<number, number>): void {
158164
const wrappedSequence = wrappedUint16.wrap(sequence);
159-
const wrapThreshold = MAX_UINT16 / 2;
165+
const SEQUENCE_SPACE = MAX_UINT16 + 1;
160166

161-
// Determine if wrappedSequence is ahead of lastAck, including wrap-around handling
162-
const isSequenceAhead =
163-
wrappedSequence > this.lastAck.get() ||
164-
(wrappedSequence < this.lastAck.get() &&
165-
this.lastAck.get() - wrappedSequence > wrapThreshold);
167+
// Forward distance from lastAck to the acked sequence, in sequence space.
168+
const distance =
169+
(wrappedSequence - this.lastAck.get() + SEQUENCE_SPACE) % SEQUENCE_SPACE;
166170

167-
// If the sequence is ahead, delete all cached data/timers up to the given ack sequence
168-
if (isSequenceAhead) {
171+
// We never have more than maxSequenceAvailable packets in flight, so an ack
172+
// further ahead than that is bogus. Without this bound a single crafted ack
173+
// costs up to 65535 cache lookups, and a sequence that wrap() cannot bring
174+
// back into range would loop forever.
175+
if (distance > 0 && distance <= this.maxSequenceAvailable) {
169176
while (this.lastAck.get() !== wrappedSequence) {
170177
this.removeFromCache(this.lastAck.get());
171178
unAckData.delete(this.lastAck.get());
@@ -176,7 +183,7 @@ export class SOEOutputStream extends EventEmitter {
176183
}
177184
}
178185
} else {
179-
// If an out-of-order ack is received, delete that specific entry if it exists
186+
// Out of order or bogus ack, delete that specific entry if it exists
180187
if (unAckData.has(wrappedSequence)) {
181188
this.removeFromCache(wrappedSequence);
182189
unAckData.delete(wrappedSequence);
@@ -217,6 +224,9 @@ export class SOEOutputStream extends EventEmitter {
217224
}
218225

219226
setFragmentSize(value: number): void {
227+
if (!Number.isInteger(value) || value < 1) {
228+
throw new Error(`Invalid fragment size: ${value}`);
229+
}
220230
this._fragmentSize = value;
221231
}
222232
}

‎src/servers/SoeServer/soeserver.ts‎

Lines changed: 69 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const debug = require("debug")("SOEServer");
2626

2727
const MAX_PACKETS_PER_IP_PER_SEC = 500;
2828
const MAX_SESSIONS_PER_IP_PER_MIN = 5;
29+
const MIN_UDP_LENGTH = 512;
30+
const MAX_UDP_LENGTH = 65535;
2931

3032
interface RateWindow {
3133
count: number;
@@ -279,8 +281,19 @@ export class SOEServer extends EventEmitter {
279281

280282
private _createClient(remote: RemoteInfo) {
281283
const client = new SOEClient(remote, this._crcSeed, this._cryptoKey);
284+
// Armed here rather than in the SessionRequest handler: a client that never
285+
// completes a session request must still expire. We delete it ourselves
286+
// because the gateway/zone disconnect path can only find clients by
287+
// sessionId, which a half-open client never sets.
288+
client.lastKeepAliveTimer = this.keepAliveTimeoutTime
289+
? setTimeout(() => {
290+
debug("Client keep alive timeout");
291+
this.deleteClient(client);
292+
this.emit("disconnect", client);
293+
}, this.keepAliveTimeoutTime)
294+
: null;
282295
// Start a persistent sending loop for this client
283-
(client as any).sendingInterval = setInterval(() => {
296+
client.sendingInterval = setInterval(() => {
284297
this.sendingProcess(client);
285298
}, this._waitTimeMs);
286299
client.inputStream.on("appdata", (data: Buffer) => {
@@ -329,7 +342,12 @@ export class SOEServer extends EventEmitter {
329342
"Received session request from " + client.address + ":" + client.port
330343
);
331344
client.sessionId = packet.session_id;
332-
client.clientUdpLength = packet.udp_length;
345+
// udp_length is attacker controlled. An unclamped value makes the
346+
// derived fragment size <= 0, which hangs writeReliable() forever.
347+
client.clientUdpLength = Math.min(
348+
Math.max(packet.udp_length >>> 0, MIN_UDP_LENGTH),
349+
MAX_UDP_LENGTH
350+
);
333351
client.protocolName = packet.protocol;
334352
client.serverUdpLength = this._udpLength;
335353
client.crcSeed = this._crcSeed;
@@ -340,14 +358,6 @@ export class SOEServer extends EventEmitter {
340358
client.outputStream.setFragmentSize(
341359
client.clientUdpLength - (4 + this._crcLength)
342360
);
343-
// setup the keep alive timer
344-
client.lastKeepAliveTimer = this.keepAliveTimeoutTime
345-
? setTimeout(() => {
346-
debug("Client keep alive timeout");
347-
this.emit("disconnect", client);
348-
}, this.keepAliveTimeoutTime)
349-
: null;
350-
351361
const sessionReply = this.createLogicalPacket(SoeOpcode.SessionReply, {
352362
session_id: client.sessionId,
353363
crc_seed: client.crcSeed,
@@ -467,37 +477,15 @@ export class SOEServer extends EventEmitter {
467477
try {
468478
if (!this._checkIpRateLimit(remote.address)) return;
469479

470-
let client: SOEClient;
471480
const clientId = SOEClient.getClientId(remote);
472481
debug(data.length + " bytes from ", clientId);
473-
// if doesn't know the client
474-
if (!this._clients.has(clientId)) {
475-
// if it's not a session request then we ignore it
476-
if (data[1] !== 1) {
477-
return;
478-
}
479-
if (!this._checkSessionRateLimit(remote.address)) return;
480-
client = this._createClient(remote);
481-
} else {
482-
client = this._clients.get(clientId) as SOEClient;
483-
}
484-
if (data[0] === 0x00) {
485-
const raw_parsed_data: string = this._protocol.parse(data);
486-
if (raw_parsed_data) {
487-
const parsed_data = JSON.parse(raw_parsed_data);
488-
if (parsed_data.name === "Error") {
489-
debug("parsing error " + parsed_data.error);
490-
debug(parsed_data);
491-
} else {
492-
if (client.lastKeepAliveTimer) {
493-
client.lastKeepAliveTimer.refresh();
494-
}
495-
this.handlePacket(client, parsed_data);
496-
}
497-
} else {
498-
debug("Unmanaged packet from client", clientId, data);
499-
}
500-
} else {
482+
483+
let client = this._clients.get(clientId);
484+
485+
// Non-SOE data is only ever valid for an established client and must
486+
// never be able to create one.
487+
if (data[0] !== 0x00) {
488+
if (!client) return;
501489
if (this._allowRawDataReception) {
502490
debug("Raw data received from client", clientId, data);
503491
this.emit("appdata", client, data, true); // Unreliable + Unordered
@@ -508,10 +496,46 @@ export class SOEServer extends EventEmitter {
508496
data
509497
);
510498
}
499+
return;
511500
}
501+
502+
// Cheap pre-filter so a junk flood never reaches the protocol parser
503+
if (!client && data[1] !== 1) return;
504+
505+
const raw_parsed_data: string = this._protocol.parse(data);
506+
if (!raw_parsed_data) {
507+
debug("Unmanaged packet from client", clientId, data);
508+
return;
509+
}
510+
511+
let parsed_data;
512+
try {
513+
parsed_data = JSON.parse(raw_parsed_data);
514+
} catch {
515+
// h1emu-core can emit malformed JSON for hostile input, drop the packet
516+
debug("Failed to parse packet JSON from " + clientId);
517+
return;
518+
}
519+
520+
if (parsed_data.name === "Error") {
521+
debug("parsing error " + parsed_data.error);
522+
debug(parsed_data);
523+
return;
524+
}
525+
526+
if (!client) {
527+
// An unknown client may only ever open a session, nothing else.
528+
if (parsed_data.name !== "SessionRequest") return;
529+
if (!this._checkSessionRateLimit(remote.address)) return;
530+
client = this._createClient(remote);
531+
}
532+
533+
if (client.lastKeepAliveTimer) {
534+
client.lastKeepAliveTimer.refresh();
535+
}
536+
this.handlePacket(client, parsed_data);
512537
} catch (e) {
513538
console.log(e);
514-
process.exitCode = 1;
515539
}
516540
}
517541

@@ -523,15 +547,17 @@ export class SOEServer extends EventEmitter {
523547
if (udpLength !== undefined) {
524548
this._udpLength = udpLength;
525549
}
526-
setInterval(() => {
550+
// Swept at a cadence matching the entry TTLs, otherwise a spoofed source
551+
// flood grows these maps unbounded between sweeps.
552+
this._packetResetInterval = setInterval(() => {
527553
const now = Date.now();
528554
for (const [ip, entry] of this._ipRateMap) {
529555
if (now - entry.windowStart > 2000) this._ipRateMap.delete(ip);
530556
}
531557
for (const [ip, entry] of this._sessionRequestMap) {
532558
if (now - entry.windowStart > 62000) this._sessionRequestMap.delete(ip);
533559
}
534-
}, 60000);
560+
}, 5000);
535561

536562
this._connection.on("message", (data, remote) => {
537563
this.onMessage(data, remote);
@@ -812,10 +838,7 @@ export class SOEServer extends EventEmitter {
812838

813839
deleteClient(client: SOEClient): void {
814840
client.isDeleted = true;
815-
if ((client as any).sendingInterval) {
816-
clearInterval((client as any).sendingInterval);
817-
(client as any).sendingInterval = null;
818-
}
841+
client.closeTimers();
819842
this._clients.delete(client.soeClientId);
820843
debug("client connection from port : ", client.port, " deleted");
821844
}

0 commit comments

Comments
 (0)