Skip to content

Commit 751c256

Browse files
committed
Teach SAMPCAC how to do detection runs
1 parent 3ae58c7 commit 751c256

File tree

9 files changed

+265
-3
lines changed

9 files changed

+265
-3
lines changed

javascript/entities/player.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export class Player extends Supplementable {
119119
#serial_ = null;
120120
#ipAddress_ = null;
121121
#isNpc_ = null;
122+
#version_ = null;
122123

123124
#selectObjectResolver_ = null;
124125

@@ -144,6 +145,7 @@ export class Player extends Supplementable {
144145
this.#serial_ = murmur3hash(this.#gpci_ || 'npc');
145146
this.#ipAddress_ = pawnInvoke('GetPlayerIp', 'iS', this.#id_);
146147
this.#isNpc_ = !!pawnInvoke('IsPlayerNPC', 'i', this.#id_);
148+
this.#version_ = this.#isNpc_ ? 'NPC' : pawnInvoke('GetPlayerVersion', 'iS', this.#id_);
147149
}
148150

149151
notifyDisconnecting() { this.#connectionState_ = Player.kConnectionClosing; }
@@ -172,6 +174,8 @@ export class Player extends Supplementable {
172174

173175
get serial() { return this.#serial_; }
174176

177+
get version() { return this.#version_; }
178+
175179
get packetLossPercentage() {
176180
return toFloat(pawnInvoke('NetStats_PacketLossPercent', 'i', this.#id_))
177181
}

javascript/entities/test/mock_player.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class MockPlayer extends Player {
2323
#ping_ = 30;
2424
#ipAddress_ = null;
2525
#isNpc_ = null;
26+
#version_ = null;
2627

2728
#isServerAdmin_ = false;
2829

@@ -80,6 +81,7 @@ export class MockPlayer extends Player {
8081
this.#serial_ = murmur3hash(this.#gpci_ || 'npc');
8182
this.#ipAddress_ = params.ip || '127.0.0.1';
8283
this.#isNpc_ = params.npc || false;
84+
this.#version_ = params.version || '0.3.7-mock';
8385

8486
this.#lastDialogPromiseResolve_ = null;
8587
this.#lastDialogPromise_ = new Promise(resolve => {
@@ -100,6 +102,8 @@ export class MockPlayer extends Player {
100102

101103
get serial() { return this.#serial_; }
102104

105+
get version() { return this.#version_; }
106+
103107
get packetLossPercentage() { return this.#packetLossPercentage_; }
104108
set packetLossPercentageForTesting(value) { this.#packetLossPercentage_ = value; }
105109

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2020 Las Venturas Playground. All rights reserved.
2+
// Use of this source code is governed by the MIT license, a copy of which can
3+
// be found in the LICENSE file.
4+
5+
// Represents a SAMPCAC detector as defined in our private JSON file. Detectors can either be based
6+
// on expected readings, or on unexpected reasons. Immutable after construction.
7+
export class Detector {
8+
// Type of supported detectors.
9+
static kTypeAllowList;
10+
static kTypeBlockList;
11+
12+
#address_ = null;
13+
#bytes_ = null;
14+
#name_ = null;
15+
#type_ = null;
16+
17+
#resultBytes_ = null;
18+
#resultChecksum_ = null;
19+
20+
constructor(detector) {
21+
if (!detector.hasOwnProperty('name') || typeof detector.name !== 'string')
22+
throw new Error(`Each detector must have a human readable name.`);
23+
24+
this.#name_ = detector.name;
25+
26+
if (!detector.hasOwnProperty('address') || typeof detector.address !== 'number')
27+
throw new Error(`${this}: Non-numeric address specified in configuration.`);
28+
29+
if (!detector.hasOwnProperty('bytes') || typeof detector.bytes !== 'number')
30+
throw new Error(`${this}: Non-numeric byte length specified in configuration.`);
31+
32+
this.#address_ = detector.address;
33+
this.#bytes_ = detector.bytes;
34+
35+
if (detector.hasOwnProperty('blocked')) {
36+
this.#type_ = Detector.kTypeBlockList;
37+
38+
this.#resultBytes_ = detector.blocked.bytes ?? null;
39+
this.#resultChecksum_ = detector.blocked.checksum ?? null;
40+
41+
} else if (detector.hasOwnProperty('expected')) {
42+
this.#type_ = Detector.kTypeAllowList;
43+
44+
this.#resultBytes_ = detector.expected.bytes ?? null;
45+
this.#resultChecksum_ = detector.expected.checksum ?? null;
46+
47+
} else {
48+
throw new Error(`${this}: Detector either has to be a blocked or allowed type.`);
49+
}
50+
51+
if (this.#resultBytes_ !== null && !Array.isArray(this.#resultBytes_))
52+
throw new Error(`${this}: Result bytes must be specified as an array.`);
53+
54+
if (this.#resultChecksum_ !== null && typeof this.#resultChecksum_ !== 'number')
55+
throw new Error(`${this}: Result checksum must be specified as a number.`);
56+
57+
if (this.#resultBytes_ === null && this.#resultChecksum_ === null)
58+
throw new Error(`${this}: Either the result bytes or checksum must be specified.`);
59+
}
60+
61+
// Gets the name for this detector, will be shown in the dialogs.
62+
get name() { return this.#name_; }
63+
64+
// Gets the address at which memory has to be read.
65+
get address() { return this.#address_; }
66+
67+
// Gets the number of bytes that have to be read from the given address.
68+
get bytes() { return this.#bytes_; }
69+
70+
// Gets the expected result of the Detector. The polarity might have to be negated depending
71+
// on the |type| of detector this instance represents.
72+
get resultBytes() { return this.#resultBytes_; }
73+
get resultChecksum() { return this.#resultChecksum_; }
74+
75+
// Gets the type of detector this instance represents.
76+
get type() { return this.#type_; }
77+
78+
// Returns a textual representation of this detector.
79+
toString() { return `[object Detector("${this.#name_}")]`; }
80+
}

javascript/features/sampcac/detector_manager.js

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,124 @@
22
// Use of this source code is governed by the MIT license, a copy of which can
33
// be found in the LICENSE file.
44

5+
import { Detector } from 'features/sampcac/detector.js';
6+
import { DetectorResults } from 'features/sampcac/detector_results.js';
7+
8+
import { equals } from 'base/equals.js';
9+
10+
// File (not checked in) in which detectors are located. Not required.
11+
const kDetectorConfiguration = 'detectors.json';
12+
513
// Time after which we consider a memory read to have timed out.
614
export const kMemoryReadTimeoutMs = 3500;
715

816
// Manages the SAMPCAC detectors that are available on the server. Will load its information from
917
// a configuration file, unless tests are active, in which case they can be injected.
1018
export class DetectorManager {
19+
detectors_ = null;
1120
natives_ = null;
1221
responseResolvers_ = null;
1322

1423
constructor(natives) {
24+
this.detectors_ = null;
1525
this.natives_ = natives;
1626
this.responseResolvers_ = new Map();
1727

1828
server.playerManager.addObserver(this);
1929
}
2030

31+
// Initializes the detectors from scratch. Will be lazily called the first time a detection run
32+
// is started for a particular player, or the detectors are being reloaded by management.
33+
initializeDetectors() {
34+
this.detectors_ = new Set();
35+
36+
let configuration = null;
37+
try {
38+
configuration = JSON.parse(readFile(kDetectorConfiguration));
39+
} catch {
40+
return; // bail out, the file does not exist
41+
}
42+
43+
if (!Array.isArray(configuration))
44+
throw new Error(`Expected the detector configuration to be an array.`);
45+
46+
for (const detectorConfiguration of configuration)
47+
this.detectors_.add(new Detector(detectorConfiguration));
48+
}
49+
50+
// ---------------------------------------------------------------------------------------------
51+
52+
// Runs the necessary checks on the given |player|, and returns an instance of DetectorResults
53+
// to communicate back the |player|'s state. Could take multiple seconds.
54+
async detect(player) {
55+
if (this.detectors_ === null)
56+
this.initializeDetectors();
57+
58+
const results = new DetectorResults();
59+
60+
// (1) Populate the meta-data fields of the results.
61+
results.version = player.version;
62+
63+
if (this.natives_.getStatus(player)) {
64+
results.sampcacVersion = this.natives_.getClientVersion(player).join('.');
65+
results.sampcacHardwareId = this.natives_.getHardwareID(player);
66+
}
67+
68+
results.minimized = player.isMinimized();
69+
70+
// (2) Run each of the detectors for the |player| in parallel and populate the results in
71+
// the |results.detectors| map. There is no expectation for that map to be sorted.
72+
results.detectors = new Map();
73+
74+
const tasks = [];
75+
76+
for (const detector of this.detectors_) {
77+
tasks.push(this.requestDetection(player, detector).then(result => {
78+
results.detectors.set(detector.name, result);
79+
}));
80+
}
81+
82+
// Wait for all the |tasks| to have been completed.
83+
await Promise.all(tasks);
84+
85+
// (3) Return the |results| to the caller who requested this detection run.
86+
return results;
87+
}
88+
89+
// Requests the |detector| to run for the given |player|. Will return the result of the request
90+
// as one of the DetectorResult.kResult* constants.
91+
async requestDetection(player, detector) {
92+
const response = await this.requestMemoryRead(player, detector.address, detector.bytes);
93+
if (response === null)
94+
return DetectorResults.kResultUnavailable;
95+
96+
// (1) Determine whether the |response| is a checksum (true) or an array of bytes (false).
97+
const isChecksum = typeof response === 'number';
98+
99+
// (2) Consider the |response| based on the type of |detector| we're dealing with.
100+
switch (detector.type) {
101+
case Detector.kTypeAllowList:
102+
if (isChecksum && detector.resultChecksum === response)
103+
return DetectorResults.kResultClean;
104+
105+
if (!isChecksum && equals(detector.resultBytes, response))
106+
return DetectorResults.kResultClean;
107+
108+
break;
109+
110+
case Detector.kTypeBlockList:
111+
if (isChecksum && detector.resultChecksum === response)
112+
return DetectorResults.kResultDetected;
113+
114+
if (!isChecksum && equals(detector.resultBytes, response))
115+
return DetectorResults.kResultDetected;
116+
117+
break;
118+
}
119+
120+
return DetectorResults.kResultUndeterminable;
121+
}
122+
21123
// ---------------------------------------------------------------------------------------------
22124

23125
// Requests a memory read from the |player| at the given |address| (in GTA_SA.exe address space)
@@ -53,8 +155,8 @@ export class DetectorManager {
53155

54156
// Request a checksum for GTA_SA.exe's .text section as the contents are well known, so we
55157
// don't need the granularity and can reduce the data being transfered.
56-
address < 0x856A00 ? this.natives_.readMemoryChecksum(player, address, bytes)
57-
: this.natives_.readMemory(player, address, bytes);
158+
this.natives_.readMemory(player, address, bytes);
159+
this.natives_.readMemoryChecksum(player, address, bytes)
58160

59161
const result = await promise;
60162

javascript/features/sampcac/detector_manager.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Use of this source code is governed by the MIT license, a copy of which can
33
// be found in the LICENSE file.
44

5+
import { DetectorResults } from 'features/sampcac/detector_results.js';
56
import { MockSAMPCACStatus } from 'features/sampcac/mock_sampcac_natives.js';
67

78
import { kMemoryReadTimeoutMs } from 'features/sampcac/detector_manager.js';
@@ -22,6 +23,23 @@ describe('DetectorManager', (it, beforeEach) => {
2223
feature.natives_.setStatusForTesting(gunther, status);
2324
})
2425

26+
it('is able to run a detection against a particular player', async (assert) => {
27+
const resultPromise = manager.detect(gunther);
28+
29+
// Fast-forward since we can't mock all the memory addresses.
30+
await server.clock.advance(kMemoryReadTimeoutMs);
31+
32+
const result = await resultPromise;
33+
34+
assert.instanceOf(result, DetectorResults);
35+
assert.equal(result.version, '0.3.7-mock');
36+
assert.typeOf(result.sampcacVersion, 'string');
37+
assert.typeOf(result.sampcacHardwareId, 'string');
38+
assert.isFalse(result.minimized);
39+
40+
assert.instanceOf(result.detectors, Map);
41+
});
42+
2543
it('is able to request memory reads from players', async (assert) => {
2644
status.writeMemoryChecksum(0x43A4B0, 1337);
2745
status.writeMemory(0x867198, [ 0x61, 0x66, 0x72, 0x6F ]); // afro
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2020 Las Venturas Playground. All rights reserved.
2+
// Use of this source code is governed by the MIT license, a copy of which can
3+
// be found in the LICENSE file.
4+
5+
// Encapsulates the results of a SAMPCAC detection run. Self-contained to enable other parts of the
6+
// code to rely on this functionality without concern.
7+
export class DetectorResults {
8+
static kResultUnavailable = 0; // results could not be obtained from the player
9+
static kResultUndeterminable = 1; // results could not be interpreted against the detector
10+
static kResultClean = 2; // results came back negative
11+
static kResultDetected = 3; // results came back positive
12+
13+
// ---------------------------------------------------------------------------------------------
14+
// Section: Meta-information about the player
15+
// ---------------------------------------------------------------------------------------------
16+
17+
// Version of the SA-MP client that they're using.
18+
version = null;
19+
20+
// Version of the SAMPCAC client that they're using. May be NULL.
21+
sampcacVersion = null;
22+
23+
// Hardware ID assigned to the player by SAMPCAC. Based on the VMProtect Hardware ID algorithm,
24+
// and is thus easily gameable. (https://helloacm.com/decode-hardware-id/)
25+
sampcacHardwareId = null;
26+
27+
// Boolean indicating whether the player is currently minimized. This influences whether results
28+
// will be made available, as their client has to respond to it.
29+
minimized = null;
30+
31+
// ---------------------------------------------------------------------------------------------
32+
// Section: Detectors
33+
// ---------------------------------------------------------------------------------------------
34+
35+
// Map of <detector name, detector result> for each of the defined detectors. Is not guaranteed
36+
// to have entries, as the detectors are not open sourced. In effectively randomized order.
37+
detectors = null;
38+
}

javascript/features/sampcac/event_monitor.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class EventMonitor extends SAMPCACEventObserver {
4949
// Called when the memory at the given |address| has been read in their GTA_SA.exe memory space,
5050
// with the actual memory contents being written to |buffer| as an Uint8Buffer.
5151
onPlayerMemoryRead(player, address, buffer) {
52-
this.manager_.onMemoryResponse(player, address, buffer);
52+
this.manager_.onMemoryResponse(player, address, [ ...buffer ]);
5353
}
5454

5555
// Called when the |player| has taken a screenshot.

javascript/features/sampcac/sampcac.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export default class SAMPCAC extends Feature {
3333
this.monitor_ = new EventMonitor(this.manager_);
3434
}
3535

36+
// ---------------------------------------------------------------------------------------------
37+
// Public API of the SAMPCAC feature
38+
// ---------------------------------------------------------------------------------------------
39+
40+
// Runs the necessary checks on the given |player|, and returns an instance of DetectorResults
41+
// to communicate back the |player|'s state. Could take multiple seconds.
42+
async detect(player) { return await this.manager_.detect(player); }
43+
44+
// ---------------------------------------------------------------------------------------------
45+
3646
dispose() {
3747
this.monitor_.dispose();
3848
this.monitor_ = null;

javascript/features/sampcac/sampcac_natives.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ export class SAMPCACNatives {
1010
static kGameOptionManualReloading = 1;
1111
static kGameOptionDriveOnWater = 2;
1212
static kGameOptionFireproof = 3;
13+
static kGameOptionSprint = 4;
1314
static kGameOptionInfiniteSprint = 5;
1415
static kGameOptionInfiniteOxygen = 6;
1516
static kGameOptionInfiniteAmmo = 7;
1617
static kGameOptionNightVision = 8;
1718
static kGameOptionThermalVision = 9;
1819

20+
// Options that can be passed to the kGameOptionSprint value.
21+
static kSprintDefault = 0;
22+
static kSprintAllSurfaces = 1;
23+
static kSprintDisabled = 2;
24+
1925
// Glitches that can be toggled with SAMPCAC.
2026
static kGlitchQuickReload = 0;
2127
static kGlitchFastFire = 1;

0 commit comments

Comments
 (0)