From 29d5d490ac8d75e14e1d86e4a76f546a3beeaa11 Mon Sep 17 00:00:00 2001 From: dapphp Date: Sun, 3 May 2020 12:14:32 -0700 Subject: [PATCH] Reference #5 - Updates to TorDNSEL; add TorDNSEL::isTor(); add TorDNSEL::getFingerprints(); IPv6 readiness for when the Tor DNSEL service supports IPv6 --- examples/TorDNSEL.php | 49 ++++++--- src/TorDNSEL.php | 232 ++++++++++++++++++++++++++++++++++------- tests/TorDNSELTest.php | 93 +++++++++++++++++ 3 files changed, 319 insertions(+), 55 deletions(-) create mode 100644 tests/TorDNSELTest.php diff --git a/examples/TorDNSEL.php b/examples/TorDNSEL.php index e5b55c3..d7c770f 100644 --- a/examples/TorDNSEL.php +++ b/examples/TorDNSEL.php @@ -7,22 +7,19 @@ // Practical usage on a web server: /* try { - $isTor = TorDNSEL::IpPort( - $_SERVER['SERVER_ADDR'], - $_SERVER['SERVER_PORT'], - $_SERVER['REMOTE_ADDR'] - ); - var_dump($isTor); + if (TorDNSEL::isTor($_SERVER['SERVER_ADDR'])) { + // do something special for Tor users + } else { + // not using Tor, educate them! :-D + } } catch (\Exception $ex) { - echo $ex->getMessage() . "\n"; + error_log("Tor DNSEL query failed: " . $ex->getMessage()); } */ // Test lookups -// First array index is the remote IP (client/potential exit node) -// Second is the server IP -// Third is the server port -// Fourth is the DNS server to query +// First array index is the remote IP (client/potential exit relay) +// second is the DNS server to use for the query (consider using your local caching resolver!) $lookups = array( array('195.176.3.20', 'check-01.torproject.org'), /* DigiGesTor4e3 */ array('185.220.103.4', '1.1.1.1'), /* CalyxInstitute16 */ @@ -37,14 +34,34 @@ list($remoteIP, $server) = $lookup; try { + echo "[o] Checking $remoteIP using server $server...\n"; + // send DNS request to Tor DNS exit list service // returns true if $remoteIP is a Tor exit relay - $isTor = TorDNSEL::IpPort(null, null, $remoteIP, $server); + $isTor = TorDNSEL::isTor($remoteIP, $server); + + if ($isTor) { + echo "[+] Tor exit relay: *YES*\n"; + } else { + echo "[-] Tor exit relay: No\n"; + echo "[-] Fingerprint(s): N/A\n"; + } + + if ($isTor) { + $fingerprints = TorDNSEL::getFingerprints($remoteIP, $server); + + if (!empty($fingerprints)) { + echo sprintf( + "[+] Fingerprint(s): %s\n", + join(', ', $fingerprints) + ); + } else { /* Service should return a fingerprint if address is an exit relay */ } + } + + echo "\n"; - echo sprintf("Connection from %s *%s* a Tor exit relay.\n", - $remoteIP, ($isTor ? 'is' : 'is NOT')); } catch (\Exception $ex) { - echo sprintf("Query for %s failed. Error: %s\n", - $remoteIP, $ex->getMessage()); + echo sprintf("[!] Query failed: %s\n", + $ex->getMessage()); } } diff --git a/src/TorDNSEL.php b/src/TorDNSEL.php index e30cb0f..b895cbf 100644 --- a/src/TorDNSEL.php +++ b/src/TorDNSEL.php @@ -36,11 +36,29 @@ * */ + namespace Dapphp\TorUtils; +/** + * Tor DNS Exit List Checker + * + * This class contains a simple DNS client meant only to know enough about the DNS protocol in order to perform + * simple DNSEL queries specific to the Tor Project's Exit List Service. TCP is not supported. Use for anything other + * than this purpose at your own risk. + * + * Other options include PHP's dns_get_record() function, but this lacks any facilities for getting error information. + * Using Pear Net_DNS2 is an alternative option. + * + * @package Dapphp\TorUtils + */ class TorDNSEL { - private $_requestTimeout = 10; + private $dnsRequestTimeout = 5; + + /** @var string[] Default list of DNS servers used for Tor DNSEL checks */ + private static $dnsServers = [ + 'check-01.torproject.org', + ]; /** * Perform a DNS lookup of an IP-port combination to the public Tor DNS @@ -58,24 +76,100 @@ class TorDNSEL * @return boolean true if the $remoteIp is a Tor exit relay */ public static function IpPort($ip, $port, $remoteIp, $dnsServer = 'check-01.torproject.org') + { + return static::isTor($remoteIp, $dnsServer); + } + + /** + * Check if a remote IP address is a Tor exit relay by querying the address against Tor DNS Exit List service. + * This check sends a DNS "A" query to the $dnsServer (or the default if none is provided) and checks the answer to + * determine if the visitor is coming from a Tor exit or not. + * + * @param $remoteAddr The remote IP address, (e.g. $_SERVER['REMOTE_ADDR']) (IPv4 or IPv6) - Note: IPv6 is not currently supported by TorDNSEL + * @param null $dnsServer The DNS resolver to query against (if null it will use check-01.torproject.or) + * Consider using a local, caching resolver for DNSEL queries! + * @return bool true if the visitor's IP address is a Tor exit relay, false if it is not + * @throws \Exception + */ + public static function isTor($remoteAddr, $dnsServer = null) + { + $response = self::query($remoteAddr, 1 /* A */, $dnsServer); + + if (!empty($response['answers']) && $response['answers'][0]['TYPE'] == 1) { + return '127.0.0.2' == $response['answers'][0]['data']; + } + + return false; + } + + /** + * Query the Tor DNS Exit List service for a list of relay fingerprints that belong to the supplied IP address. + * If $remoteAddr is not a Tor exit relay, an empty array is returned. + * + * @param $remoteAddr The Tor exit relay IP address (IPv4 or IPv6) - Note: IPv6 is not currently supported by TorDNSEL + * @param null $dnsServer The DNS resolver to query against (if null it will use check-01.torproject.or) + * Consider using a local, caching resolver for DNSEL queries! + * @return array An array of Tor relay fingerprints, if the IP address is a Tor exit relay + * @throws \Exception If there is a network error or the DNS query fails + */ + public static function getFingerprints($remoteAddr, $dnsServer = null) + { + $response = self::query($remoteAddr, 16 /* TXT */, $dnsServer); + $fingerprints = []; + + foreach($response['answers'] as $answer) { + if ($answer['TYPE'] == 16) { + $fingerprints[] = $answer['data']; + } + } + + return $fingerprints; + } + + /** + * Query something + * + * @param string $remoteAddr + * @param int $queryType + * @param string|null $dnsServer + * @return array + * @throws \Exception + */ + protected static function query($remoteAddr, $queryType, $dnsServer = null) { $dnsel = new self(); - // construct a hostname in the format of {rip}.{port}.{ip}.ip-port.exitlist.torproject.org - // where {ip} is the destination IP address and {port} is the destination port - // and {rip} is the remote (user) IP address which may or may not be a Tor router exit address + if (empty($dnsServer)) { + $servers = self::$dnsServers; + shuffle($servers); + $dnsServer = $servers[0]; + } + + return $dnsel->dnsLookup($dnsel->getTorDNSELName($remoteAddr), $queryType, $dnsServer); + } + + public function __construct() {} - $host = sprintf( + public function getTorDNSELName($remoteIp) + { + // construct a hostname in the format of {remote_ip}.dnsel.torproject.org + // where {remote_ip} is the remote (client) IP address which may or may not be a Tor exit relay + + if (strpos($remoteIp, ':') !== false) { + $addr = $this->expandIPv6Address($remoteIp); + } elseif (strpos($remoteIp, '.') !== false) { + $addr = implode('.', array_reverse(explode('.', $remoteIp))); + } else { + throw new \InvalidArgumentException('Invalid IPv6/IPv6 address'); + } + + return sprintf( '%s.%s', - implode('.', array_reverse(explode('.', $remoteIp))), + $addr, 'dnsel.torproject.org' ); - - return $dnsel->_dnsLookup($host, $dnsServer); } - private function __construct() {} - /** * Perform a DNS lookup to the Tor DNS exit list service and determine * if the remote connection could be a Tor exit node. @@ -85,26 +179,31 @@ private function __construct() {} * @throws \Exception DNS failures, socket failures * @return boolean */ - private function _dnsLookup($host, $dnsServer) + private function dnsLookup($host, $type, $dnsServer) { - $query = $this->_generateDNSQuery($host); - $data = $this->_performDNSLookup($query, $dnsServer); + $query = $this->generateDNSQuery($host, $type); + $data = $this->performDNSLookup($query, $dnsServer); if (!$data) { throw new \Exception('DNS request timed out'); } - $response = $this->_parseDNSResponse($data); + $response = $this->parseDnsResponse($data); + $this->assertPositiveResponse($response); - //var_dump($response); + if (substr($query, 0, 2) != substr($data, 0, 2)) { + // query transaction ID does not match + throw new \Exception('DNS answer packet transaction ID mismatch'); + } + + return $response; + } + public function assertPositiveResponse($response) + { switch($response['header']['RCODE']) { case 0: - if (isset($response['answers'][0]) && '127.0.0.2' == $response['answers'][0]['data']) { - return true; - } else { - return false; - } + return true; break; case 1: @@ -139,11 +238,12 @@ private function _dnsLookup($host, $dnsServer) * simple DNS "A" query for the given hostname. * * @param string $host Hostname used in the query + * @param int $queryType * @return string */ - private function _generateDNSQuery($host) + private function generateDNSQuery($host, $queryType) { - $id = rand(1, 0x7fff); + $id = mt_rand(1, 0x7fff); $req = pack('n6', $id, // Request ID 0x100, // standard query @@ -153,9 +253,9 @@ private function _generateDNSQuery($host) 0 // additional RRs ); - foreach(explode('.', $host) as $bit) { + foreach (explode('.', $host) as $bit) { // split name levels into bits - $l = strlen($bit); + $l = strlen($bit); // append query with length of segment, and the domain bit $req .= chr($l) . $bit; } @@ -164,7 +264,7 @@ private function _generateDNSQuery($host) $req .= "\0"; $req .= pack('n2', - 1, // type A + $queryType, 1 // class IN ); @@ -180,7 +280,7 @@ private function _generateDNSQuery($host) * @throws \Exception Failed to send UDP packet * @return string DNS response or empty string if request timed out */ - private function _performDNSLookup($query, $dns_server, $port = 53) + private function performDNSLookup($query, $dns_server, $port = 53) { $fp = fsockopen('udp://' . $dns_server, $port, $errno, $errstr); @@ -190,7 +290,7 @@ private function _performDNSLookup($query, $dns_server, $port = 53) fwrite($fp, $query); - socket_set_timeout($fp, $this->_requestTimeout); + socket_set_timeout($fp, $this->dnsRequestTimeout); $resp = fread($fp, 8192); return $resp; @@ -203,7 +303,7 @@ private function _performDNSLookup($query, $dns_server, $port = 53) * @throws \Exception Failed to parse response (malformed) * @return array Array with parsed response */ - private function _parseDnsResponse($data) + private function parseDnsResponse($data) { $p = 0; $offset = array(); @@ -233,6 +333,10 @@ private function _parseDnsResponse($data) $header['RA'] = ($flags >> 7) & 1; $header['RCODE'] = ($flags & 0x0f); + if ($header['QR'] != 1) { + throw new \Exception('DNS response QR flag is not set to response (1)'); + } + // read count fields $counts = unpack('n4', substr($data, $p, 8)); $p += 8; @@ -249,19 +353,19 @@ private function _parseDnsResponse($data) $records['additional'] = array(); for ($i = 0; $i < $header['QDCOUNT']; ++$i) { - $records['questions'][] = $this->_readDNSQuestion($data, $p); + $records['questions'][] = $this->readDNSQuestion($data, $p); } for ($i = 0; $i < $header['ANCOUNT']; ++$i) { - $records['answers'][] = $this->_readDNSRR($data, $p); + $records['answers'][] = $this->readDNSRR($data, $p); } for ($i = 0; $i < $header['NSCOUNT']; ++$i) { - $records['authority'][] = $this->_readDNSRR($data, $p); + $records['authority'][] = $this->readDNSRR($data, $p); } for ($i = 0; $i < $header['ARCOUNT']; ++$i) { - $records['additional'][] = $this->_readDNSRR($data, $p); + $records['additional'][] = $this->readDNSRR($data, $p); } return array( @@ -280,7 +384,7 @@ private function _parseDnsResponse($data) * @param number $offset Starting offset of $data to begin reading * @return string The DNS name in the packet */ - private function _readDNSName($data, &$offset) + private function readDNSName($data, &$offset) { $name = array(); @@ -296,7 +400,7 @@ private function _readDNSName($data, &$offset) $off = unpack('n', substr($data, $offset - 1, 2)); $offset += 1; $noff = $off[1] & 0x3fff; - $name[] = $this->_readDNSName($data, $noff); + $name[] = $this->readDNSName($data, $noff); break; } else { // name segment precended by the length of the segment @@ -316,10 +420,10 @@ private function _readDNSName($data, &$offset) * @param number $offset Starting offset of $data to begin reading * @return array Array with question information */ - private function _readDNSQuestion($data, &$offset) + private function readDNSQuestion($data, &$offset) { $question = array(); - $name = $this->_readDNSName($data, $offset); + $name = $this->readDNSName($data, $offset); $type = unpack('n', substr($data, $offset, 2)); $offset += 2; @@ -340,11 +444,11 @@ private function _readDNSQuestion($data, &$offset) * @param number $offset Starting offset of $data to begin reading * @return array Array with RR information */ - private function _readDNSRR($data, &$offset) + private function readDNSRR($data, &$offset) { $rr = array(); - $rr['name'] = $this->_readDNSName($data, $offset); + $rr['name'] = $this->readDNSName($data, $offset); $fields = unpack('nTYPE/nCLASS/NTTL/nRDLENGTH', substr($data, $offset, 10)); $offset += 10; @@ -384,10 +488,60 @@ private function _readDNSRR($data, &$offset) case 2: // NS $temp = $offset - $fields['RDLENGTH']; - $rr['data'] = $this->_readDNSName($data, $temp); + $rr['data'] = $this->readDNSName($data, $temp); + break; + + case 16: // TXT + $txtLength = ord(substr($rr['RDATA'], 0, 1)); + $rr['data'] = substr($rr['RDATA'], 1, $txtLength); + break; } return $rr; } + + public function expandIPv6Address($address) + { + if (empty($address)) { + throw new \InvalidArgumentException('IPv6 address is empty'); + } + + $address = strtolower((string)$address); + $address = preg_replace('/^\[|\]$/', '', $address); + + if ( + substr_count($address, ':') < 2 + || + preg_match('/[^a-f0-9:]/', $address) + ) { + throw new \InvalidArgumentException('Invalid IPv6 address'); + } + + $hextets = explode(':', $address); + $fill = null; + + for ($i = 0; $i < sizeof($hextets); ++$i) { + if ($hextets[$i] == '') { + if (!is_null($fill)) { + $hextets[$i] = '0000'; + } else { + $fill = $i; + } + } else { + $hextets[$i] = str_pad($hextets[$i], 4, '0', STR_PAD_LEFT); + } + } + + if (!is_null($fill)) { + array_splice($hextets, $fill, 1, '0000'); + while (8 > sizeof($hextets)) { + array_splice($hextets, $fill, 0, '0000'); + } + } + + $expanded = join('', $hextets); + + return join('.', array_reverse(preg_split('//', $expanded, -1, PREG_SPLIT_NO_EMPTY))); + } } diff --git a/tests/TorDNSELTest.php b/tests/TorDNSELTest.php new file mode 100644 index 0000000..db08b08 --- /dev/null +++ b/tests/TorDNSELTest.php @@ -0,0 +1,93 @@ +getTorDNSELName($remoteaddr); + + $this->assertEquals('1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.dnsel.torproject.org', $name); + + $remoteaddr = '[2001:db8:85a3:8d3:1319:8a2e:370:7348]'; + $name = $dnsel->getTorDNSELName($remoteaddr); + + $this->assertEquals('8.4.3.7.0.7.3.0.e.2.a.8.9.1.3.1.3.d.8.0.3.a.5.8.8.b.d.0.1.0.0.2.dnsel.torproject.org', $name); + + $remoteaddr = '1.2.3.4'; + $name = $dnsel->getTorDNSELName($remoteaddr); + + $this->assertEquals('4.3.2.1.dnsel.torproject.org', $name); + + $remoteaddr = '29.58.116.203'; + $name = $dnsel->getTorDNSELName($remoteaddr); + + $this->assertEquals('203.116.58.29.dnsel.torproject.org', $name); + } + + /** + * @dataProvider expandIPv6AddressDataProvider + */ + public function testExpandIPv6Address($address, $expected) + { + $dnsel = new TorDNSEL(); + $result = $dnsel->expandIPv6Address($address); + + $this->assertEquals($expected, $result); + } + + public function expandIPv6AddressDataProvider() + { + return [ + [ '::', '0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0', ], + [ '::1', '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0', ], + + [ '2001:db8::1', '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2', ], + [ '2001:0db8::0001', '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2', ], + [ '2001:db8:0:0:0:0:2:1', '1.0.0.0.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2', ], + [ '2001:db8::2:1', '1.0.0.0.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2', ], + [ '2001:db8::1:0:0:1', '1.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2', ], + [ '2001:0db8:0000:0000:0001:0000:0000:0001', + '1.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2', ], + + [ '[2001:db8:85a3:8d3:1319:8a2e:370:7348]', + '8.4.3.7.0.7.3.0.e.2.a.8.9.1.3.1.3.d.8.0.3.a.5.8.8.b.d.0.1.0.0.2', ], + + [ 'fe80::1ff:fe23:4567:890a', 'a.0.9.8.7.6.5.4.3.2.e.f.f.f.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.e.f', ], + [ 'fdda:5cc1:23:4::1f', 'f.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.4.0.0.0.3.2.0.0.1.c.c.5.a.d.d.f', ], + [ '2001:b011:4006:170c::11', '1.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.c.0.7.1.6.0.0.4.1.1.0.b.1.0.0.2', ], + [ '2620:6e:a001:705:face:b00c:15:bad', + 'd.a.b.0.5.1.0.0.c.0.0.b.e.c.a.f.5.0.7.0.1.0.0.a.e.6.0.0.0.2.6.2', ], + [ '2620:7:6001::101', '1.0.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.6.7.0.0.0.0.2.6.2', ], + [ '[2a0a:3840:1337:125:0:b9c1:7d9b:1337]', + '7.3.3.1.b.9.d.7.1.c.9.b.0.0.0.0.5.2.1.0.7.3.3.1.0.4.8.3.a.0.a.2', ], + ]; + } + + /** + * @dataProvider invalidIPv6AddressesDataProvider + */ + public function testInvalidIPv6Addresses($address) + { + $dnsel = new TorDNSEL(); + + $this->expectException(\InvalidArgumentException::class); + + $dnsel->expandIPv6Address($address); + } + + public function invalidIPv6AddressesDataProvider() + { + return [ + [ '', ], + [ ':', ], + [ '[:]', ], + [ '1.2.3.4', ], + [ '2620:7:6001::101z', ], + ]; + } +}