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; } } ?>