This repository has been archived on 2024-07-18. You can view files and clone it, but cannot push or open issues or pull requests.
lwb5/scripts/game/main/ticks/deathofrats/library.inc

1468 lines
43 KiB
PHP

<?php
//-----------------------------------------------------------------------
// LegacyWorlds Beta 5
// Engine ticks
//
// main/ticks/deathofrats/library.inc
//
// This tick is the Death of Rats.
//
// Ok, actually, it's the AMS. It checks for open proxies and other
// signs of suspicious behaviour.
//
// Copyright(C) 2004-2008, DeepClone Development
//-----------------------------------------------------------------------
class main_ticks_deathofrats_library {
/** How long does punishment last? */
const punishmentDays = 20;
/** How long before we reaxime an account after it's been warned? */
const postWarningGrace = 7;
/** Delay in hours between ingame checks on suspicious accounts */
const inGameCheckDelay = 4;
/** Levels for attempts to conceal pass-sharing */
private static $viciousLevels = array(
1 => array('VICIOUS-LP'),
2 => array('VICIOUS-MP', 'VICIOUS-LP'),
3 => array('VICIOUS-HP', 'VICIOUS-MP', 'VICIOUS-LP'),
);
/** Single player badness points, days at maximum score and days with
* decreasing score.
*/
private static $spBadness = array(
// Action => array( Points, MaxDays, DecDays )
"PROXY" => array( 40, 30, 30 ),
"CLCOOK-SIP" => array( 5, 2, 1 ),
"CLCOOK-DIP" => array( 2, 2, 1 ),
"BAT" => array( 80, 60, 60 )
);
/** Multiplayer badness points, days at maximum score and days with
* decreasing score.
*/
private static $mpBadness = array(
// Action => array( Points, MaxDays, DecDays )
"SIMPLE" => array( 15, 6, 1 ),
"SIMPLE-10" => array( 18, 6, 1 ),
"VICIOUS-LP" => array( 15, 3, 3 ),
"VICIOUS-MP" => array( 30, 7, 7 ),
"VICIOUS-HP" => array( 50, 21, 15 ),
"PASS" => array( 90, 90, 90 ),
"NOPASS" => array( -20, 90, 30 )
);
/** In-game badness points, days at maximum score and days with
* decreasing score.
*/
private static $igBadness = array(
// Action => array( Points, MaxDays, DecDays )
"LSE" => array( 5, 3, 1 ),
"SE" => array( 10, 4, 3 ),
"HSE" => array( 15, 5, 5 ),
"VHSE" => array( 20, 8, 7 )
);
private $lastExec;
private $now;
private $connections;
private $perAccount;
private $proxiedAccounts;
private $nEvents;
private $browserNames;
private $nChanges;
private $userPlays = array();
public function __construct($lib) {
$this->lib = $lib;
$this->game = $this->lib->game;
$this->db = $this->game->db;
config::$main['debug'] = 2;
}
public function runTick() {
$this->nEvents = $this->nChanges = 0;
$this->now = time();
$this->browserNames = array();
// Fetch the time at which the Death of Rats was last activated
sleep(1);
$this->lastExec = $this->db->safeTransaction(array($this, "getLastExec"));
if ($this->lastExec == $this->now) {
l::notice("Not running the Death of Rats - last execution is now");
return;
}
// Execute account-level checks
$this->accountChecks();
// Execute in-game checks
$ingameChecks = $this->db->safeTransaction(array($this, 'getInGameCheckList'));
if (count($ingameChecks)) {
l::debug("Executing in-game checks");
foreach ($ingameChecks as $pair) {
$this->db->safeTransaction(array($this, 'checkInGamePair'), $pair);
}
}
$finalPoints = $this->db->safeTransaction(array($this, 'finalizeInGameData'));
// Take action if needed
foreach ($finalPoints as $id => $points) {
if ($points < 100) {
continue;
}
$accounts = explode(',', $id);
$this->db->safeTransaction(array($this, 'takeAction'), $accounts);
}
}
/** This method performs account-level checks.
*/
private function accountChecks() {
// If the Death of Rats had never been activated, act as if it had
// been one hour earlier, and check accounts with identical
// password.
if (is_null($this->lastExec)) {
l::notice("First execution, checking identical passwords");
$this->lastExec = $this->now - 3600;
$this->db->safeTransaction(array($this, 'checkIdenticalPassword'));
} else {
l::debug("Checking password changes");
$this->nChanges += $this->db->safeTransaction(array($this, 'checkPassChange'));
}
// For each player who logged in or created an account, check
// single-player suspicious behaviour.
$this->connections = $this->db->safeTransaction(array($this, 'getLastLogons'));
$this->nChanges += count($this->connections);
if (! $this->nChanges) {
l::info("No new records, skipping account-level checks.");
return;
}
// If we had connection records...
if (!empty($this->connections)) {
l::debug("Analysing " . count($this->connections) . " new record(s)");
$this->makePerAccountRecords();
// Start with open proxies
l::debug("Checking for open proxies ...");
$this->checkOpenProxies();
if (count($this->proxiedAccounts)) {
l::info("Logging " . count($this->proxiedAccounts) . " account(s) using open proxies");
$this->db->safeTransaction(array($this, 'logOpenProxies'));
}
// Now examine per-account entries to find different types of rats
l::debug("Checking single player badness");
foreach ($this->perAccount as $records) {
$this->db->safeTransaction(array($this, 'checkSinglePlayer'), array($records));
}
// Check for different players logging on from the same IP and on the same tracking
l::debug("Checking simple multiing / pass sharing");
$this->db->safeTransaction(array($this, 'checkSimpleMultis'));
// Check for players who clear cookies before logging on to another account
l::debug("Checking vicious multiing / pass sharing");
$this->db->safeTransaction(array($this, 'checkViciousMultis'));
}
// Flush the main logs
$this->db->safeTransaction(array($this, 'finalizeMainData'));
}
/** This method returns the last execution time of the Death of Rats.
*/
public function getLastExec() {
$q = $this->db->query("SELECT ts FROM dor_exec ORDER BY ts DESC LIMIT 1");
if (dbCount($q)) {
list($last) = dbFetchArray($q);
} else {
$last = null;
}
return $last;
}
/** This method flushes both logs and adds the execution entry
*/
public function finalizeMainData() {
l::debug("Updating main Death of Rats status");
$this->db->query(
"LOCK TABLE dor_exec, dor_single, dor_multi, pass_change, dor_single_points, dor_multi_points "
. "IN ACCESS EXCLUSIVE MODE"
);
$this->flushSinglePlayerLog();
$this->flushMultiPlayerLog();
$this->db->query(
"INSERT INTO dor_exec (ts, entries, events) "
. "VALUES ({$this->now}, {$this->nChanges}, {$this->nEvents})"
);
$this->db->query("DELETE FROM pass_change WHERE ts < {$this->now}");
$this->db->query("DELETE FROM dor_exec WHERE ts < {$this->now} - 7 * 24 * 3600");
$spBadness = $this->computeSinglePlayerBadness();
$this->computeMultiPlayerBadness($spBadness);
}
/** This method flushes the in-game log and computes final badness
* points for each player.
*/
public function finalizeInGameData() {
l::debug("Updating Death of Rats status for in-game checks");
$this->flushInGameLog();
return $this->computeInGameBadness();
}
/***********************************************************************
* ACTIONS TO PERFORM ON POSITIVE DECISIONS *
***********************************************************************/
/** Main action handler for a pair of accounts that have been
* positively identified by the Death of Rats.
*/
public function takeAction($account1, $account2) {
// Check and send warnings; return if no further
// action is required.
if ($this->checkWarnings($account1, $account2)) {
return;
}
// Punish the accounts if needed
$this->checkPunishment($account1, $account2);
}
/** This function checks the status of two accounts on the "warning"
* angle of things.
*/
private function checkWarnings($account1, $account2) {
// Check if both accounts were warned
$q = $this->db->query(
"SELECT account1,MAX(ts) FROM dor_warning "
. "WHERE account1 IN ($account1, $account2) "
. "GROUP BY account1"
);
if (dbCount($q) == 0) {
// Both accounts need a warning
$this->sendWarning($account1, $account2);
$this->sendWarning($account2, $account1);
return true;
} elseif (dbCount($q) == 2) {
// Both accounts have been warned, check the grace
// period.
list($account, $mts) = dbFetchArray($q);
list($account, $mts2) = dbFetchArray($q);
$mts = ($mts > $mts2) ? $mts : $mts2;
return ($this->now - $mts > self::postWarningGrace * 86400);
}
// One of the accounts has never been warned, send him a warning
list($account, $mts) = dbFetchArray($q);
if ($account == $account1) {
$this->sendWarning($account2, $account1);
} else {
$this->sendWarning($account1, $account2);
}
return true;
}
/** This method sends a warning to a player about him being flagged with
* another player.
*/
private function sendWarning($account1, $account2) {
l::trace(" Warning account #$account1");
$this->db->query(
"INSERT INTO dor_warning (account1, account2, ts) "
. "VALUES ($account1, $account2, {$this->now})"
);
// FIXME: should send messages in all games they play together
}
/** This method checks whether two accounts should be punished.
*/
private function checkPunishment($account1, $account2) {
$q = $this->db->query(
"SELECT account FROM dor_punishment "
. "WHERE account IN ($account1, $account2) "
. "AND {$this->now} - ts < " . self::punishmentDays * 86400
);
if (dbCount($q) >= 2) {
// Both are already punished
return;
} elseif (dbCount($q) == 0) {
// Punish both accounts
$this->punish($account1, $account2);
$this->punish($account2, $account1);
return;
}
// Punish the account that is still unpunished
list($account) = dbFetchArray($q);
$this->punish($account == $account1 ? $account2 : $account1,
$account == $account1 ? $account1 : $account2);
}
/** This method punishes a player for abusive pass sharing or multiing
* with another player.
*/
private function punish($account1, $account2) {
l::trace(" PUNISHING ACCOUNT #$account1");
$this->db->query(
"INSERT INTO dor_punishment (account, other_account, ts) "
. "VALUES ($account1, $account2, {$this->now})"
);
// FIXME: should send messages in all games they play together
}
/***********************************************************************
* BADNESS POINTS COMPUTATION *
***********************************************************************/
private function computeSinglePlayerBadness() {
// Generate the array of query parts
$qParts = array();
foreach (self::$spBadness as $type => $data) {
array_push($qParts, "(message = '$type' AND {$this->now} - ts < "
. (24 * 3600 * ($data[1] + $data[2])) . ")");
}
// Retrieve entries
$qString = "SELECT * FROM dor_single WHERE " . join(" OR ", $qParts) . " ORDER BY ts";
$q = $this->db->query($qString);
// Compute points
$points = array();
while ($r = dbFetchHash($q)) {
$pPoints = $points[$r['account']];
$badness = self::$spBadness[$r['message']];
$bPoints = $badness[0];
$time = $this->now - $r['ts'];
if ($time > $badness[1] * 24 * 3600) {
$time -= $badness[1] * 24 * 3600;
$time = ($badness[2] * 24 * 3600) - $time;
$bPoints = round($bPoints * $time / ($badness[2] * 24 * 3600));
}
$points[$r['account']] = max(0, $pPoints + $bPoints);
}
// Insert points
$spData = new db_copy("dor_single_points", db_copy::copyToClean);
$spData->setAccessor($this->db);
foreach ($points as $account => $bPoints) {
if ($bPoints == 0) {
continue;
}
$spData->appendRow(array($account, $bPoints));
}
$spData->execute();
return $points;
}
private function computeMultiPlayerBadness($spBadness) {
// Generate the array of query parts
$qParts = array();
foreach (self::$mpBadness as $type => $data) {
array_push($qParts, "(message = '$type' AND {$this->now} - ts < "
. (24 * 3600 * ($data[1] + $data[2])) . ")");
}
// Retrieve entries
$qString = "SELECT * FROM dor_multi WHERE " . join(" OR ", $qParts) . " ORDER BY ts";
$q = $this->db->query($qString);
// Compute points
$points = array();
while ($r = dbFetchHash($q)) {
$id = $r['account1'] . ',' . $r['account2'];
$pPoints = $points[$id];
$badness = self::$mpBadness[$r['message']];
$bPoints = $badness[0];
$time = $this->now - $r['ts'];
if ($time > $badness[1] * 24 * 3600) {
$time -= $badness[1] * 24 * 3600;
$time = ($badness[2] * 24 * 3600) - $time;
$bPoints = round($bPoints * $time / ($badness[2] * 24 * 3600));
}
$points[$id] = max(0, $pPoints + $bPoints);
}
// Add single player badness to records
foreach ($points as $id => $bPoints) {
$accounts = explode(',', $id);
$bPoints += ceil(($spBadness[$accounts[0]] + $spBadness[$accounts[1]]) / 2);
$points[$id] = $bPoints;
}
// Insert points
$mpData = new db_copy("dor_multi_points", db_copy::copyToClean);
$mpData->setAccessor($this->db);
foreach ($points as $id => $bPoints) {
if ($bPoints == 0) {
continue;
}
$accounts = explode(',', $id);
$mpData->appendRow(array($accounts[0], $accounts[1], $bPoints));
}
$mpData->execute();
}
private function computeInGameBadness() {
// Generate the array of query parts
$qParts = array();
foreach (self::$igBadness as $type => $data) {
array_push($qParts, "(message LIKE '$type-%' AND {$this->now} - ts < "
. (24 * 3600 * ($data[1] + $data[2])) . ")");
}
// Retrieve entries
$qString = "SELECT * FROM dor_ingame_check WHERE " . join(" OR ", $qParts) . " ORDER BY ts";
$q = $this->db->query($qString);
// Compute points
$points = array();
while ($r = dbFetchHash($q)) {
$id = $r['account1'] . ',' . $r['account2'];
$pPoints = $points[$id];
list($message, $count) = explode("-", $r['message']);
$badness = self::$igBadness[$message];
$bPoints = $badness[0] * $count;
$time = $this->now - $r['ts'];
if ($time > $badness[1] * 24 * 3600) {
$time -= $badness[1] * 24 * 3600;
$time = ($badness[2] * 24 * 3600) - $time;
$bPoints = round($bPoints * $time / ($badness[2] * 24 * 3600));
}
$points[$id] = max(0, $pPoints + $bPoints);
}
// Insert points
$igData = new db_copy("dor_final_points", db_copy::copyToClean);
$igData->setAccessor($this->db);
foreach ($points as $id => $bPoints) {
if ($bPoints == 0) {
continue;
}
$accounts = explode(',', $id);
$igData->appendRow(array($accounts[0], $accounts[1], $bPoints));
}
$igData->execute();
return $points;
}
/***********************************************************************
* VARIOUS HELPER METHODS *
***********************************************************************/
/** This method compares two IP addresses. It returns 2 if they are equal,
* 1 if they are on the same /24, or 0 if they don't match at all.
*/
static private function ipSimilarity($ip1, $ip2) {
if ($ip1 == $ip2) {
return 2;
}
$l = strrpos($ip1, ".");
if (is_bool($l)) {
l::notice("IP address without dots, that's weird ... IP was \"$ip1\"");
return 0;
}
if (substr($ip1, 0, $l + 1) == substr($ip2, 0, $l + 1)) {
return 1;
}
return 0;
}
/** This method returns the name of the browser associated with a
* tracking cookie ID.
*/
private function getBrowserName($id) {
if (is_null($this->browserNames[$id])) {
$q = $this->db->query("SELECT browser FROM web_tracking WHERE id = $id");
list($this->browserNames[$id]) = dbFetchArray($q);
}
return $this->browserNames[$id];
}
/***********************************************************************
* PASSWORDS *
***********************************************************************/
/** This method checks the list of accounts for identical passwords. It is
* only called once, the first time the Death of Rats is started.
*/
public function checkIdenticalPassword() {
$q = $this->db->query(
"SELECT password FROM account "
. "WHERE status IN ('STD', 'VAC', 'NEW') "
. "GROUP BY password "
. "HAVING COUNT(*) > 1"
);
while ($r = dbFetchArray($q)) {
$qId = $this->db->query(
"SELECT id FROM account "
. "WHERE password = '" . addslashes($r[0]) . "' "
. "AND status IN ('STD', 'VAC', 'NEW')"
);
l::notice("Found " . dbCount($qId) . " accounts with password '{$r[0]}'");
$accounts = array();
while ($rID = dbFetchArray($qId)) {
array_push($accounts, $rID[0]);
}
for ($i = 0; $i < count($accounts) - 1; $i ++) {
for ($j = $i + 1; $j < count($accounts); $j ++) {
$this->multiPlayerLog("PASS", $accounts[$i], $accounts[$j]);
}
}
}
}
/** This method checks for players changing their password to another
* player's password.
*/
public function checkPassChange() {
// Get all password changes
$q = $this->db->query("SELECT * FROM pass_change ORDER BY ts");
if (!dbCount($q)) {
return 0;
}
// Create per-account changes list
$accounts = array();
while ($r = dbFetchHash($q)) {
if (!is_array($accounts[$r['account']])) {
$accounts[$r['account']] = array("changes" => array());
}
if (is_null($accounts[$r['account']]['initial'])) {
$accounts[$r['account']]['initial'] = addslashes($r['old_pass']);
}
$pass = addslashes($r['new_pass']);
$accounts[$r['account']]['last'] = $pass;
}
// For each account, verify the changes
foreach ($accounts as $account => $data) {
if ($data['initial'] == $data['last']) {
continue;
}
// Get accounts that use these passwords
$passwords = array();
if ($data['initial'] != '') {
$passwords[] = $data['initial'];
}
$passwords[] = $data['last'];
$q = $this->db->query(
"SELECT id, password FROM account "
. "WHERE id <> $account AND password IN ('" . join("','", $passwords) . "')"
);
if (!dbCount($q)) {
continue;
}
$otherList = array();
while ($r = dbFetchArray($q)) {
$r[1] = addslashes($r[1]);
if (!is_array($otherList[$r[1]])) {
$otherList[$r[1]] = array($r[0]);
} else {
$otherList[$r[1]][] = $r[0];
}
}
// If the old password was used by another account, mark it
if ($data['initial'] != '' && is_array($otherList[$data['initial']])) {
foreach ($otherList[$data['initial']] as $account2) {
$this->multiPlayerLog("NOPASS", $account, $account2);
}
}
// If the new password is used by another account, mark it
if (is_array($otherList[$data['last']])) {
foreach ($otherList[$data['last']] as $account2) {
$this->multiPlayerLog("PASS", $account, $account2);
}
}
}
return count($accounts);
}
/***********************************************************************
* ACCESSING PREVIOUS RECORDS *
***********************************************************************/
/** This method lists the latest entries from the account_log table.
*/
public function getLastLogons() {
$q = $this->db->query(
"SELECT account, tracking, ip_addr, action, t FROM account_log "
. "WHERE action IN ('IN', 'OUT', 'CREATE') "
. "AND t > {$this->lastExec} AND t <= {$this->now}"
. "ORDER BY t"
);
$records = array();
while ($r = dbFetchHash($q)) {
array_push($records, $r);
}
return $records;
}
/** This method goes through all records read from the database and
* creates separate lists for each account.
*/
public function makePerAccountRecords() {
$paRecords = array();
foreach ($this->connections as $record) {
if (!is_array($paRecords[$record['account']])) {
$paRecords[$record['account']] = array();
}
array_push($paRecords[$record['account']], $record);
}
$this->perAccount = $paRecords;
}
/***********************************************************************
* OPEN PROXIES *
***********************************************************************/
/** This method checks for open proxies in the latest log entries.
*/
private function checkOpenProxies() {
$IPs = array();
// Make lists of accounts for each IP
foreach ($this->connections as $record) {
if ($record['ip_addr'] == 'AUTO' || $record['action'] == 'OUT') {
continue;
}
$ip = $record['ip_addr'];
$account = $record['account'];
if (!is_array($IPs[$ip])) {
$IPs[$ip] = array($account);
} elseif (!in_array($account, $IPs[$ip])) {
array_push($IPs[$ip], $account);
}
}
// Check for proxies on the IPs
$requests = array();
$proxies = array();
foreach (array_keys($IPs) as $ip) {
if (count($requests) < 20) {
array_push($requests, $ip);
continue;
}
try {
$results = pcheck::check($requests);
} catch (Exception $e) {
l::error("Failed to check some addresses for open proxies");
l::info($e->getMessage());
return;
}
foreach ($results as $host => $status) {
if ($status == 1) {
array_push($proxies, $host);
}
}
$requests = array();
}
// If there are some requests we didn't execute, do it
if (count($requests)) {
try {
$results = pcheck::check($requests);
} catch (Exception $e) {
l::error("Failed to check some addresses for open proxies");
l::info($e->getMessage());
return;
}
foreach ($results as $host => $status) {
if ($status == 1) {
array_push($proxies, $host);
}
}
}
// Check for proxied accounts
$proxyAccounts = array();
foreach ($proxies as $ip) {
foreach ($IPs[$ip] as $account) {
if (in_array($account, $proxyAccounts)) {
continue;
}
array_push($proxyAccounts, $account);
}
}
$this->proxiedAccounts = $proxyAccounts;
}
/** This method logs access to accounts using open proxies. A log
* entry is only added every 24h.
*/
public function logOpenProxies() {
// Get all recent open proxy logs
$this->db->query(
"SELECT account FROM dor_single "
. "WHERE message = 'PROXY' AND {$this->now} - ts < 86400"
);
$recent = array();
while ($r = dbFetchArray($q)) {
$recent[] = $r[0];
}
// Insert proxy logs
foreach ($this->proxiedAccounts as $account) {
if (in_array($account, $recent)) {
continue;
}
$this->singlePlayerLog("PROXY", $account);
}
}
/***********************************************************************
* SINGLE PLAYER SUSPICIOUS BEHAVIOUR *
***********************************************************************/
public function checkSinglePlayer($records) {
// Ignore accounts that have already been kicked
$q = $this->db->query(
"SELECT status FROM account WHERE id = {$records[0]['account']}"
);
list($status) = dbFetchArray($q);
if ($status == 'KICKED') {
return;
}
l::trace("CHECKING SINGLE PLAYER {$records[0]['account']}");
// Check log-ons for people who have just tripped the "banned log-in" detector
$baDetected = false;
foreach ($records as $record) {
if ($record['action'] == 'OUT') {
continue;
}
if ($record['action'] == 'CREATE') {
$time = 300;
} else {
$time = 120;
}
$q = $this->db->query(
"SELECT * FROM banned_attempt "
. "WHERE ip_addr = '{$record['ip_addr']}' AND ts <= {$record['t']} "
. "AND {$record['t']} - ts <= $time"
);
if (dbCount($q)) {
$baDetected = true;
break;
}
}
// Banned log-in attempt detected, check for a similar record in the past 2 weeks
if ($baDetected) {
$q = $this->db->query(
"SELECT * FROM dor_single "
. "WHERE account = {$records[0]['account']} AND message = 'BAT' "
. "AND {$this->now} - ts < 14 * 24 * 3600"
);
if (!dbCount($q)) {
$this->singlePlayerLog('BAT', $records[0]['account']);
}
}
// If the first record isn't the account's creation, get
// the previous record
if ($records[0]['action'] != 'CREATE') {
$q = $this->db->query(
"SELECT account, tracking, ip_addr, action, t FROM account_log "
. "WHERE account = {$records[0]['account']} AND t <= {$this->lastExec} "
. "AND (action = 'IN' OR action = 'CREATE')"
. "ORDER BY t DESC LIMIT 1"
);
if (dbCount($q)) {
array_unshift($records, dbFetchHash($q));
l::trace(" Found previous action with tracking {$records[0]['tracking']}");
}
}
// For each couple of records in the list, check for cookie deletion
$lastExam = null;
for ($i = 1; $i < count($records); $i ++) {
if (is_null($records[$i]['tracking']) || $records[$i]['ip_addr'] == 'AUTO') {
continue;
}
l::trace(" Checking record with tracking ID {$records[$i]['tracking']} (action '"
. "{$records[$i]['action']}')");
// Find previous valid record
for ($j = $i - 1; $j >= 0; $j --) {
if ($records[$j]['action'] == 'OUT' && $records[$j]['ip_addr'] == 'AUTO'
|| is_null($records[$j]['tracking'])
|| $records[$j]['tracking'] == $records[$i]['tracking']) {
continue;
}
break;
}
if ($j < 0 || $j === $lastExam) {
continue;
}
$lastExam = $j;
l::trace(" Found suspicious records: {$records[$j]['tracking']} / {$records[$i]['tracking']}");
// Check IP address
$ipCheck = self::ipSimilarity($records[$j]['ip_addr'], $records[$i]['ip_addr']);
l::trace(" IP similarity: $ipCheck");
if ($ipCheck == 0) {
continue;
}
// Get browser string for each tracking ID
$browser1 = $this->getBrowserName($records[$j]['tracking']);
$browser2 = $this->getBrowserName($records[$i]['tracking']);
$leven = levenshtein($browser1, $browser2);
l::trace(" Browser similarity: $leven");
if ($leven > 10) {
// *Probably* 2 different browsers, skip
continue;
}
// Check for earlier records that would contradict cookie deletion
$q = $this->db->query(
"SELECT COUNT(*) FROM account_log "
. "WHERE t < {$records[$j]['t']} AND tracking = {$records[$i]['tracking']}"
);
list($count) = dbFetchArray($q);
if ($count > 0) {
l::trace(" Found older uses of tracking ID {$records[$i]['tracking']}"
. ", multiple computers");
continue;
}
l::trace(" ADDING RECORD");
$this->singlePlayerLog($ipCheck == 2 ? "CLCOOK-SIP" : "CLCOOK-DIP", $records[$i]['account']);
}
}
/**********************************************
* MULTI PLAYERS DETECTION *
**********************************************/
/** This method gets all tracking cookie IDs used in the records, and
* generates an "extended" record including the previous X actions on
* each tracking record.
*/
private function extendRecord($nActions = 1) {
// Get the list of all tracking cookies used in the log
$tracking = array();
foreach ($this->connections as $record) {
if (is_null($record['tracking'])) {
continue;
}
if (!in_array($record['tracking'], $tracking)) {
$tracking[] = $record['tracking'];
}
}
if (!empty($tracking)) {
// First we need to get the previous record for all tracking cookies
$q = $this->db->query(
"SELECT account, tracking, ip_addr, action, t FROM account_log "
. "WHERE tracking IN (" . join(',', $tracking) . ") "
. "AND t <= {$this->lastExec} "
. "AND action IN ('IN', 'CREATE', 'OUT') "
. "ORDER BY t DESC"
);
// Create the list of records to merge into the log fragment
$extraRecords = array();
$okTrackings = array();
while (count($extraRecords) < $nActions * count($tracking) && $r = dbFetchHash($q)) {
if ($okTrackings[$r['tracking']] == $nActions) {
continue;
}
$extraRecords[] = $r;
$okTrackings[$r['tracking']] ++;
}
} else {
$extraRecords = array();
}
return array_merge(array_reverse($extraRecords), $this->connections);
}
/** This method checks for the simplest type of multi accounts,
* but also catches occasional pass-sharers.
*/
public function checkSimpleMultis() {
// Get the extra actions we need
$connections = $this->extendRecord();
// Extract the guys who got caught logging in on the same browser
// from the list of records.
$caught = array();
foreach (array_keys($this->perAccount) as $account) {
// Find all tracking IDs used by this account
l::trace("CHECKING ACCOUNT $account");
$aTrack = array();
foreach ($connections as $record) {
if (!is_null($record['tracking']) && $record['account'] == $account
&& !in_array($record['tracking'], $aTrack)) {
$aTrack[] = $record['tracking'];
}
}
// Find all other accounts who have a record with the same tracking
foreach ($aTrack as $tracking) {
l::trace(" Checking tracking #$tracking");
$lastUse = null; $prevAccount = null;
foreach ($connections as $record) {
if ($tracking != $record['tracking']) {
continue;
}
if (is_null($lastUse)) {
$lastUse = $record['t'];
$prevAccount = $record['account'];
l::trace(" Last tracking use by $prevAccount");
continue;
}
if ($record['account'] != $prevAccount) {
l::trace(" Tracking used by different account, {$record['account']}");
$bothAccounts = array($record['account'], $prevAccount);
sort($bothAccounts);
$bothAccounts = join(',', $bothAccounts);
$time = ($record['t'] - $lastUse <= 10) ? 2 : 1;
if ($time > $caught[$bothAccounts]) {
$caught[$bothAccounts] = $time;
}
$prevAccount = $record['account'];
}
$lastUse = $record['t'];
}
}
}
// Adds the log entries
$nFound = 0;
foreach ($caught as $accounts => $type) {
list($a1, $a2) = explode(',', $accounts);
$nFound ++;
$this->multiPlayerLog($type == 2 ? "SIMPLE-10" : "SIMPLE", $a1, $a2);
}
if ($nFound) {
l::notice("$nFound simple multiing / pass sharing attempt(s) found");
}
}
/** This method checks for vicious multi accounts: people who clear
* cookies before logging on to other accounts. To perform this check,
* the game will start by looking up 'IN' records from any account using
* a different tracking cookie ID but the same browser and a close IP
* address. For each different account found, it will check the sequence
* of tracking cookies used.
*/
public function checkViciousMultis() {
$caught = array();
foreach ($this->perAccount as $account => $records) {
l::trace(" Verifying account $account");
for ($i = 0; $i < count($records); $i ++) {
$recPair = $this->findInterestingPair($records, $i);
if (is_null($recPair)) {
continue;
}
list($R1, $R2) = $recPair;
l::trace(" Found pair of records: {$R1['tracking']} vs {$R2['tracking']} ("
. gmstrftime("%H:%M:%S %Y-%m-%d", $R1['t']) . " / "
. gmstrftime("%H:%M:%S %Y-%m-%d", $R2['t']) . ")");
// Get the list of logons between R1 and R2 and
// presenting ... troubling ... similarities
$R3List = $this->findIntermediaryLogons($R1, $R2);
if (empty($R3List)) {
continue;
}
// Check the records in the list of intermediary
// logons.
foreach ($R3List as $R3) {
$caughtID = array($account, $R3['account']);
sort($caughtID);
$caughtID = join(',', $caughtID);
if ($caught[$caughtID] == 3) {
// We already caught this pair as
// VICIOUS-HP, don't recheck
continue;
}
l::trace(" Checking triplet with record from account {$R3['account']} "
. "(tracking {$R3['tracking']})");
$check = $this->checkViciousTriplet($R1, $R2, $R3);
if ($caught[$caughtID] < $check) {
$caught[$caughtID] = $check;
}
}
}
}
// For each guy we caught, add an entry to the log if one doesn't exist
// in the past 6 hours with a probability at least as high.
$nVicious = 0;
foreach ($caught as $ids => $level) {
list($id1, $id2) = explode(',', $ids);
$levels = self::$viciousLevels[$level];
$q = $this->db->query(
"SELECT COUNT(*) FROM dor_multi "
. "WHERE account1 = $id1 AND account2 = $id2 "
. "AND message IN ('" . join("','", $levels) . "') "
. "AND {$this->now} - ts <= 6 * 3600"
);
list($flagged) = dbFetchArray($q);
if ($flagged > 0) {
continue;
}
$nVicious ++;
$this->multiPlayerLog($levels[0], $id1, $id2);
}
if ($nVicious) {
l::notice("$nVicious vicious multiing attempt(s) found");
}
}
/** This method scans the log for two records from the same player,
* starting at the $i-th record in the list, where the IP is close,
* the browser is similar and the tracking is different.
*/
private function findInterestingPair($records, $i) {
$record = $records[$i];
if ($record['action'] != 'IN') {
return null;
}
// Try getting a previous record that matches
$found = null;
for ($j = $i - 1; $j >= 0; $j --) {
$oRecord = $records[$j];
if ($oRecord['tracking'] == $record['tracking']) {
// Found a previous record with the same tracking cookie
return null;
}
if ($oRecord['action'] != 'IN' || ! self::ipSimilarity($record['ip_addr'], $oRecord['ip_addr'])
|| $oRecord['tracking'] == $record['tracking']) {
continue;
}
$browser1 = $this->getBrowserName($record['tracking']);
$browser2 = $this->getBrowserName($oRecord['tracking']);
if ($browser1 != $browser2 && levenshtein($brower1, $browser2) > 10) {
// *Probably* 2 different browsers, skip
continue;
}
$found = $oRecord;
break;
}
// If we didn't find a matching record in the list, try finding
// one from the database
if (is_null($found)) {
$classC = substr($record['ip_addr'], 0, strrpos($record['ip_addr'], '.') - 1) . "%";
$q = $this->db->query(
"SELECT account, tracking, ip_addr, action, t FROM account_log "
. "WHERE account = {$record['account']} AND action = 'IN' "
. "AND tracking <> {$record['tracking']} AND tracking IS NOT NULL "
. "AND t < {$record['t']} AND ip_addr LIKE '$classC' "
. "ORDER BY t DESC LIMIT 10"
);
if (!dbCount($q)) {
// Nothing found, return
}
while ($oRecord = dbFetchHash($q)) {
// Check browsers
$browser1 = $this->getBrowserName($record['tracking']);
$browser2 = $this->getBrowserName($oRecord['tracking']);
if ($browser1 != $browser2 && levenshtein($brower1, $browser2) > 10) {
// *Probably* 2 different browsers, skip
continue;
}
// Check if there are intermediary records
$q2 = $this->db->query(
"SELECT COUNT(*) FROM account_log "
. "WHERE account = {$record['account']} AND action = 'IN' "
. "AND tracking = {$record['tracking']} "
. "AND t < {$record['t']} AND t > {$oRecord['t']}"
);
if (dbCount($q2)) {
continue;
}
$found = $oRecord;
break;
}
if (is_null($found)) {
return null;
}
}
return array($record, $found);
}
/** This method checks the database for logons that present the
* following characteristics compared to R1 and R2:
* R3.account != R1.account
* R3.ip close to R1.ip
* R3.browser =~ R1.browser
* R3.tracking != R1.tracking
* R3.tracking != R2.tracking
* R3.timestamp BETWEEN R1.timestamp AND R2.timestamp
*/
private function findIntermediaryLogons($R1, $R2) {
// Get all logons matching the specifications, except
// for the part about the browser which must be checked
// manually.
$classC = substr($record['ip_addr'], 0, strrpos($record['ip_addr'], '.') - 1) . "%";
$q = $this->db->query(
"SELECT account, tracking, ip_addr, action, t FROM account_log "
. "WHERE account <> {$R1['account']} AND tracking <> {$R1['tracking']} "
. "AND tracking <> {$R2['tracking']} AND ip_addr LIKE '$classC' "
. "AND t > {$R2['t']} AND t < {$R1['t']}"
);
if (!dbCount($q)) {
return array();
}
// Check the browser names
$results = array();
$browser1 = $this->getBrowserName($R1['tracking']);
while ($R3 = dbFetchHash($q)) {
$browser2 = $this->getBrowserName($R3['tracking']);
if ($browser1 == $browser2 || levenshtein($browser1, $browser2) <= 10) {
array_push($results, $R3);
}
}
return $results;
}
/** This method verifies a triplet of records and tries to determine
* whether they were attempts at "vicious multiing".
*/
public function checkViciousTriplet($R1, $R2, $R3) {
// Check if there are previous records R4 such as:
// R4.account = R3.account
// R4.tracking = R3.tracking
// R4.timestamp < R2.timestamp
$q = $this->db->query(
"SELECT COUNT(*) FROM account_log "
. "WHERE account = {$R3['account']} AND tracking = {$R3['tracking']} "
. "AND t < {$R3['t']}"
);
list($R4Exists) = dbFetchArray($q);
if ($R4Exists > 0) {
return 0;
}
// Get browser names
$browser1 = $this->getBrowserName($R1['tracking']);
$browser2 = $this->getBrowserName($R2['tracking']);
$browser3 = $this->getBrowserName($R3['tracking']);
// Compute browser similarities for (R1,R2) and (R1,R3)
$brSim12 = ($browser1 == $browser2) ? 2 : ((levenshtein($browser1, $browser2) <= 10) ? 1 : 0);
$brSim13 = ($browser1 == $browser3) ? 2 : ((levenshtein($browser1, $browser3) <= 10) ? 1 : 0);
$brSim23 = ($browser2 == $browser3) ? 2 : ((levenshtein($browser2, $browser3) <= 10) ? 1 : 0);
// Compute IP similarities for (R1,R2) and (R1,R3)
$ipSim12 = self::ipSimilarity($R1['ip_addr'], $R2['ip_addr']);
$ipSim13 = self::ipSimilarity($R1['ip_addr'], $R3['ip_addr']);
$ipSim23 = self::ipSimilarity($R2['ip_addr'], $R3['ip_addr']);
if ($ipSim12 == 2 && $ipSim13 != 2 || $brSim12 == 2 && $brSim13 != 2) {
return 0; // Probably not vicious multiing
} elseif ($ipSim12 == 2 && $ipSim13 == 2 && $brSim12 == 2 && $brSim13 == 2) {
return 3; // High probability
} elseif ($ipSim12 == 1 && ($ipSim13 == 2 || $ipSim23 == 2) && $brSim12 == 2 && $brSim13 == 2) {
return 3; // High probability
} elseif ($ipSim12 == 1 && ($ipSim13 == 2 || $ipSim23 == 2) && $brSim12 == 1
&& ($brSim13 == 2 || $brSim23 == 2)) {
return 2; // Medium probability
} else {
return 1; // Low probability
}
}
/**********************************************
* IN-GAME CHECKS *
**********************************************/
/** This method lists players who should be further investigated
* by the Death of Rats.
*/
public function getInGameCheckList() {
// Get all pairs of players which have a score higher than 75
$q = $this->db->query(
"SELECT account1, account2 FROM dor_multi_points WHERE points >= 75"
);
if (!dbCount($q)) {
return array();
}
// Create a unique list
$potentialChecks = array();
$uniquePlayers = array();
while ($r = dbFetchArray($q)) {
sort($r);
$id = join(',', $r);
if (! array_key_exists($id, $potentialChecks)) {
$potentialChecks[$id] = $r;
}
array_push($uniquePlayers, $r[0]);
array_push($uniquePlayers, $r[1]);
}
$uniquePlayers = array_unique($uniquePlayers);
// Get accounts that are no longer active
$q = $this->db->query(
"SELECT id FROM account "
. "WHERE id IN (" . join(',', $uniquePlayers) . ") "
. "AND status NOT IN ('STD', 'VAC')"
);
$noCheckPlayers = array();
while ($r = dbFetchArray($q)) {
$noCheckPlayers[] = $r[0];
}
// Check accounts that have been punished recently
$q = $this->db->query(
"SELECT account FROM dor_punishment "
. "WHERE account IN (" . join(',', $uniquePlayers) . ") "
. "AND {$this->now} - ts <= " . (self::punishmentDays * 86400)
);
while ($r = dbFetchArray($q)) {
$noCheckPlayers[] = $r[0];
}
// Check pairs that have been warned recently
$q = $this->db->query(
"SELECT account1, account2 FROM dor_warning "
. "WHERE {$this->now} - ts <= " . (self::postWarningGrace * 86400)
);
$noCheckPairs = array();
while ($r = dbFetchArray($q)) {
$noCheckPairs[] = join(',', $r);
}
// Get the list of recent checks on players
$q = $this->db->query(
"SELECT account1, account2 FROM dor_ingame_check "
. "WHERE {$this->now} - ts <= " . (self::inGameCheckDelay * 3600)
. "AND message = 'CHECK' "
. "GROUP BY account1, account2"
);
while ($r = dbFetchArray($q)) {
$noCheckPairs[] = join(',', $r);
}
// Create the list of pairs to check
$noCheckPlayers = array_unique($noCheckPlayers);
$noCheckPairs = array_unique($noCheckPairs);
$checkPairs = array();
foreach ($potentialChecks as $id => $accounts) {
if (in_array($id, $noCheckPairs) || in_array($accounts[0], $noCheckPlayers)
|| in_array($accounts[1], $noCheckPlayers)) {
continue;
}
array_push($checkPairs, $accounts);
}
return $checkPairs;
}
/** This method checks a pair of accounts for in-game suspicious behaviour.
*/
public function checkInGamePair($account1, $account2) {
foreach (config::getGames() as $game) {
if ($game->name == 'main' || ($game->status() != 'RUNNING' && $game->status() != 'ENDING')) {
continue;
}
if (! ($p1 = $this->doesUserPlay($game, $account1))
|| ! ($p2 = $this->doesUserPlay($game, $account2))) {
continue;
}
// Get events and log them
$events = $this->runGameChecks($game, $p1, $p2);
if (count($events) > 1) {
l::trace(" Found " . (count($events) - 1) . " suspicious event(s)");
}
foreach ($events as $eType) {
$this->inGameLog($game->name, $eType, $account1, $account2);
}
}
}
/** This method checks whether an account plays a specific game.
*/
private function doesUserPlay($game, $account) {
if (is_null($this->userPlays[$game->name . "-" . $account])) {
$this->userPlays[$game->name . "-" . $account] = $game->getLib()->call(
'doesUserPlay', $account
);
}
return $this->userPlays[$game->name . "-" . $account];
}
/** This method runs checks on a pair of players in a specific game.
*/
private function runGameChecks($game, $p1, $p2) {
l::trace(" Investigating players #$p1 and #$p2 in game \"" . $game->text . "\"");
return $game->getLib()->call('investigate', $p1, $p2,
$this->now, self::inGameCheckDelay * 3600);
}
/**********************************************
* LOG METHODS FOR PLAYER ENTRIES *
**********************************************/
private function initSinglePlayerLog() {
if (!is_null($this->spLog)) {
return;
}
$this->spLog = new db_copy("dor_single", db_copy::copyTo);
$this->spLog->setAccessor($this->db);
}
private function singlePlayerLog($logType, $id) {
$this->initSinglePlayerLog();
$this->spLog->appendRow(array(
$logType, $id, $this->now
));
$this->nEvents ++;
}
private function flushSinglePlayerLog() {
if (is_null($this->spLog)) {
return;
}
$this->spLog->execute();
$this->spLog = null;
}
/**********************************************
* LOG METHODS FOR PLAYER/PLAYER ENTRIES *
**********************************************/
private function initMultiPlayerLog() {
if (!is_null($this->mpLog)) {
return;
}
$this->mpLog = new db_copy("dor_multi", db_copy::copyTo);
$this->mpLog->setAccessor($this->db);
}
private function multiPlayerLog($logType, $id1, $id2) {
$this->initMultiPlayerLog();
$this->mpLog->appendRow(array(
$logType, $id1, $id2, $this->now
));
$this->mpLog->appendRow(array(
$logType, $id2, $id1, $this->now
));
$this->nEvents ++;
}
private function flushMultiPlayerLog() {
if (is_null($this->mpLog)) {
return;
}
$this->mpLog->execute();
$this->mpLog = null;
}
/**********************************************
* LOG METHODS FOR IN-GAME CHECKS *
**********************************************/
private function initInGameLog() {
if (!is_null($this->igLog)) {
return;
}
$this->igLog = new db_copy("dor_ingame_check", db_copy::copyTo);
$this->igLog->setAccessor($this->db);
}
private function inGameLog($game, $logType, $id1, $id2) {
$this->initInGameLog();
$this->igLog->appendRow(array(
$id1, $id2, $logType, $this->now, $game
));
}
private function flushInGameLog() {
if (is_null($this->igLog)) {
return;
}
$this->igLog->execute();
$this->igLog = null;
}
}
?>