begin, $match->end - $match->begin + 1); # only return the matches that contain an actual substitution if (mb_strtolower($token) === $match->matchedWord) { continue; } # filter single-character l33t matches to reduce noise. # otherwise '1' matches 'i', '4' matches 'a', both very common English words # with low dictionary rank. if (mb_strlen($token) === 1) { continue; } $display = []; foreach ($map as $i => $t) { if (mb_strpos($token, (string)$i) !== false) { $match->sub[$i] = $t; $display[] = "$i -> $t"; } } $match->token = $token; $match->subDisplay = implode(', ', $display); $matches[] = $match; } } Matcher::usortStable($matches, [Matcher::class, 'compareMatches']); return $matches; } /** * @param string $password * @param int $begin * @param int $end * @param string $token * @param array $params An array with keys: [sub, sub_display]. */ public function __construct(string $password, int $begin, int $end, string $token, array $params = []) { parent::__construct($password, $begin, $end, $token, $params); if (!empty($params)) { $this->sub = $params['sub'] ?? []; $this->subDisplay = $params['sub_display'] ?? null; } } #[ArrayShape(['warning' => 'string', 'suggestions' => 'string[]'])] public function getFeedback(bool $isSoleMatch): array { $feedback = parent::getFeedback($isSoleMatch); $feedback['suggestions'][] = "Predictable substitutions like '@' instead of 'a' don't help very much"; return $feedback; } /** * @param string $string * @param array $map * @return string */ protected static function translate(string $string, array $map): string { return str_replace(array_keys($map), array_values($map), $string); } protected static function getL33tTable(): array { return [ 'a' => ['4', '@'], 'b' => ['8'], 'c' => ['(', '{', '[', '<'], 'e' => ['3'], 'g' => ['6', '9'], 'i' => ['1', '!', '|'], 'l' => ['1', '|', '7'], 'o' => ['0'], 's' => ['$', '5'], 't' => ['+', '7'], 'x' => ['%'], 'z' => ['2'], ]; } protected static function getL33tSubtable(string $password): array { // The preg_split call below is a multibyte compatible version of str_split $passwordChars = array_unique(preg_split('//u', $password, -1, PREG_SPLIT_NO_EMPTY)); $subTable = []; $table = static::getL33tTable(); foreach ($table as $letter => $substitutions) { foreach ($substitutions as $sub) { if (in_array($sub, $passwordChars)) { $subTable[$letter][] = $sub; } } } return $subTable; } protected static function getL33tSubstitutions(array $subtable): array { $keys = array_keys($subtable); $substitutions = self::substitutionTableHelper($subtable, $keys, [[]]); // Converts the substitution arrays from [ [a, b], [c, d] ] to [ a => b, c => d ] $substitutions = array_map(function (array $subArray): array { return array_combine(array_column($subArray, 0), array_column($subArray, 1)); }, $substitutions); return $substitutions; } protected static function substitutionTableHelper(array $table, array $keys, array $subs): array { if (empty($keys)) { return $subs; } $firstKey = array_shift($keys); $otherKeys = $keys; $nextSubs = []; foreach ($table[$firstKey] as $l33tCharacter) { foreach ($subs as $sub) { $dupL33tIndex = false; foreach ($sub as $index => $char) { if ($char[0] === $l33tCharacter) { $dupL33tIndex = $index; break; } } if ($dupL33tIndex === false) { $subExtension = $sub; $subExtension[] = [$l33tCharacter, $firstKey]; $nextSubs[] = $subExtension; } else { $subAlternative = $sub; array_splice($subAlternative, $dupL33tIndex, 1); $subAlternative[] = [$l33tCharacter, $firstKey]; $nextSubs[] = $sub; $nextSubs[] = $subAlternative; } } } $nextSubs = array_unique($nextSubs, SORT_REGULAR); return self::substitutionTableHelper($table, $otherKeys, $nextSubs); } protected function getRawGuesses(): float { return parent::getRawGuesses() * $this->getL33tVariations(); } protected function getL33tVariations(): float { $variations = 1; foreach ($this->sub as $substitution => $letter) { $characters = preg_split('//u', mb_strtolower($this->token), -1, PREG_SPLIT_NO_EMPTY); $subbed = count(array_filter($characters, function ($character) use ($substitution) { return (string)$character === (string)$substitution; })); $unsubbed = count(array_filter($characters, function ($character) use ($letter) { return (string)$character === (string)$letter; })); if ($subbed === 0 || $unsubbed === 0) { // for this sub, password is either fully subbed (444) or fully unsubbed (aaa) // treat that as doubling the space (attacker needs to try fully subbed chars in addition to // unsubbed.) $variations *= 2; } else { $possibilities = 0; for ($i = 1; $i <= min($subbed, $unsubbed); $i++) { $possibilities += Binomial::binom($subbed + $unsubbed, $i); } $variations *= $possibilities; } } return $variations; } }