ActiveTK's Note

【ダークウェブ】中学生がOnionドメインのURLを収集して検索エンジンを作ってみた


作成日時 2022/12/31 11:13
最終更新 2023/08/04 11:24


  • まず初めに
  • クローラーの作成
  • クローリング実行
  • 一か月放置した結果
  • 検索エンジン開発

  • まず初めに

    TorのOnionサービス(Hidden Service)には、62桁(v3)のOnionドメインが割り当てられます。
    例えば、私が運営しているHSは以下のドメインです。

    activetkqz22r3lvvvqeos5qnbrwfwzjajlaljbrqmybsooxjpkccpid.onion
    

    このドメインはランダムに生成されるので、どこかからリンクしない限り勝手に第三者に接続されてしまう事はありません。
    そのため、そもそもダークウェブ上にどれほどのサイトがあるのかは不明です。一説によると、サーフェスウェブはウェブの約1%にすぎません。
    しかし、実際にどれほどのサービスがダークウェブ上に存在するのかは調査しなければ分かりません。

    そこで、今回はダークウェブ上のウェブサイトをクローリングして、Onionのサイトを収集してみたいと思います。


    クローラーの作成

    まずは、Onionドメイン専用のクローラーを開発する必要があります。
    今回はC#のdotnetで作ろうと思います(WinでもLinuxでも動作するため)。

    ひとまず、収集したURLを管理するためにMySQLを用意します。

    CREATE TABLE `darkneturls` (
      `OnionURL` varchar(220) DEFAULT NULL,
      `AccessedDate` varchar(15) DEFAULT '',
      `Body` varchar(12000) DEFAULT '',
      `ResHeaders` varchar(1000) DEFAULT '{}',
      `IsItDownOrNot` varchar(2) DEFAULT NULL,
      `AccessError` varchar(200) DEFAULT NULL,
      `PageTitle` varchar(255) DEFAULT '',
      `ContentType` varchar(40) DEFAULT '',
      `StatusCode` varchar(5) DEFAULT '',
      `ContentLength` varchar(20) DEFAULT ''
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
    

    プログラム側でコネクションを張ります。

    string server = "localhost";string database = "atk";
    string user = "root";
    string pass = "p@ssword";
    string charset = "utf8";
    string connstr = string.Format("Server={0};Database={1};Uid={2};Pwd={3};Charset={4}", server, database, user, pass, charset);
    
    MySqlConnection conn = new MySqlConnection(connstr);
    conn.Open();
    
    var Researcher = new Research
    {
           TorPort = 9050,
           DataBaseConnection = conn,
           DataBaseConnectionString = connstr,
           LogFile = LogFile
    };
    
    var MainThread = new Thread(new ThreadStart(() => {
            Researcher.StartWithOutURL();
    }));
    MainThread.Start();
    

    クローリング部分(class Research)を作っていきます。 まず、socks5://localhost:9050にTorの串を開けてHttpClientに刺します。 ついでにUAなども通常のTorブラウザに似せておきます。

    var handler = new HttpClientHandler();handler.Proxy = new HttpToSocks5Proxy("127.0.0.1", 9050);
    handler.UseProxy = true;
    var client = new HttpClient(handler);
    client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0");
    client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
    client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip, deflate, br");
    client.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.5");
    client.DefaultRequestHeaders.Add("Sec-Fetch-Dest", "document");
    client.DefaultRequestHeaders.Add("Sec-Fetch-Mode", "navigate");
    client.DefaultRequestHeaders.Add("Sec-Fetch-Site", "none");
    client.DefaultRequestHeaders.Add("Sec-Fetch-User", "?1");
    client.DefaultRequestHeaders.Add("Upgrade-Insecure-Requests", "1");
    client.Timeout = TimeSpan.FromMilliseconds(60000);
    

    Onionドメインへのfetchを独自に実装します。
    また、レスポンス保存用のclassを作っておきます。

        public class DarkNetResponese    {
            public decimal AccessedDate = 0;
            public string OnionURL { get; set; }
            public bool IsItDownOrNot { get; set; }
            public string Body { get; set; }
            public string RawBody { get; set; }
            public string ResponeseHeaders { get; set; }
            public string Error { get; set; }
            public string ContentType { get; set; }
            public string PageTitle { get; set; }
            public string StatusCode { get; set; }
            public string ContentLength { get; set; }
        }
    
    class Research{
          async private Task fetch(string OnionURL, HttpClient WebClient)
          {
                var res = new DarkNetResponese();
                res.OnionURL = OnionURL;
                res.IsItDownOrNot = true;
                res.AccessedDate = (int)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds;
                res.Error = "";
                res.PageTitle = "";
                res.StatusCode = "";
                res.ContentLength = "0";
    
                var OnionURI = new Uri(OnionURL);
                if (OnionURI.DnsSafeHost.Substring(
                      OnionURI.DnsSafeHost.LastIndexOf(".") + 1,
                      OnionURI.DnsSafeHost.Length - OnionURI.DnsSafeHost.LastIndexOf(".") - 1
                    ) != "onion" && !IsFirstReading)
                {
                    res.Error = "URL is not Onion Domain";
                    return res;
                }
    
                if (IsFirstReading)
                    IsFirstReading = false;
    
                try
                {
                    HttpResponseMessage res_raw = await WebClient.GetAsync("http://" + OnionURI.DnsSafeHost + OnionURI.PathAndQuery);
                    res.Body = await res_raw.Content.ReadAsStringAsync();
                    res.StatusCode = (((int)res_raw.StatusCode) * 1).ToString();
                    try
                    {
                        res.ContentLength = res_raw.Content.Headers.ContentLength.ToString();
                    }
                    catch
                    {
                        res.ContentLength = res.Body.Length.ToString();
                    }
    
                    if (!res_raw.IsSuccessStatusCode)
                    {
                        res.Error = "ErrorStatusCode: " + res_raw.StatusCode.ToString();
                        return res;
                    }
                    else if (res.Body == "")
                    {
                        res.Error = "Connection Timed Out";
                        return res;
                    }
    
                    res.ResponeseHeaders = "";
                    string ThisContentType = "text/html";
                    try
                    {
                        ThisContentType = res_raw.Content.Headers.ContentType.ToString();
                    }
                    catch { }
    
                    res.ResponeseHeaders = Enumerable
                        .Empty<(String name, String value)>()
                        .Concat(
                            res_raw.Headers
                            .SelectMany(kvp => kvp.Value
                                .Select(v => (name: kvp.Key, value: v))
                            )
                        )
                        .Concat(
                            res_raw.Content.Headers
                            .SelectMany(kvp => kvp.Value
                                .Select(v => (name: kvp.Key, value: v))
                            )
                        )
                        .Aggregate(
                            seed: new StringBuilder(),
                            func: (sb, pair) => sb.Append(pair.name).Append(": ").Append(pair.value).AppendLine(),
                            resultSelector: sb => sb.ToString()
                        );
    
                    try
                    {
                        if (Cut(ThisContentType, '/')[0] == "text")
                        {
                            res.RawBody = new Regex(@"<[^>]+>|]+>").Replace(res.Body, "").Replace("\r", "").Replace("\n", "").Replace("\t", "").Replace(" ", "");
                            res.PageTitle = new Regex(@"(?<title>.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline).Match(res.Body).Groups["title"].Value;
                        }
                        else
                        {
                            res.RawBody = "base64:" + ThisContentType + ";" + Convert.ToBase64String(Encoding.GetEncoding("UTF-8").GetBytes(res.Body));
                            res.PageTitle = "[File] " + ThisContentType;
                        }
                    }
                    catch { }
                    res.ContentType = ThisContentType;
                    res.IsItDownOrNot = false;
                }
                catch (Exception e)
                {
                    res.Error = e.Message;
                }
                return res;
           }
    }
    

    レスポンスのbodyからハイパーリンクを収集し、v3のみに選別します。

    foreach (Match m in new Regex(@"]*href\s*=\s*[""'](?[^""']*)[""'][^>]*>(?[^<]*)", RegexOptions.IgnoreCase).Matches(res.Body)){
        string URLi = "";
        try
        {
            var tmpurl = m.Groups["href"].ToString();
            Uri OnionURI = new Uri(uri, tmpurl);
            URLi = OnionURI.AbsoluteUri.Replace("file://", "http://");
            if (URLi.Contains("#"))
            {
                WriteLine("  (pass)FOUND_URL[" + i + "] → " + URLi);
                continue;
            }
    
            if (OnionURI.DnsSafeHost.Substring(
                OnionURI.DnsSafeHost.LastIndexOf(".") + 1,
                OnionURI.DnsSafeHost.Length - OnionURI.DnsSafeHost.LastIndexOf(".") - 1
            ) != "onion")
            {
                WriteLine("  (ClearNet)FOUND_URL[" + i + "] → " + URLi);
                continue;
            }
            else if (OnionURI.DnsSafeHost.Length != 62)
            {
                WriteLine("  (v2)FOUND_URL[" + i + "] → " + URLi);
                continue;
            }
            ForegroundColor = ConsoleColor.Blue;
            WriteLine("  (v3)FOUND_URL[" + i + "] → " + URLi);
            ResetColor();
        } catch {}
    }
    

    クローリング実行

    早速クローラーを実行していきます。
    WindowsのVS2022で開発しましたが、長時間放置には適していないので、サイトの運営に使用しているVPSで実行しようと思います。
    sshを切断してもクローリングを続けてもらうため、systemctlでサービスとして登録します。

    [root@vps ~]# cat /etc/systemd/system/dnr.service[Unit]
    Description=DarkNet Researcher
    Documentation=
    After=tor.service
    
    [Service]
    Type=simple
    User=root
    Group=root
    TimeoutStartSec=0
    Restart=on-failure
    RestartSec=30s
    ExecStart=/usr/bin/dotnet /home/activetk/apps/dnr/dnr.dll
    SyslogIdentifier=Diskutilization
    
    [Install]
    WantedBy=multi-user.target
    
    [root@vps ~]# systemctl status dnr● dnr.service - DarkNet Researcher
       Loaded: loaded (/etc/systemd/system/dnr.service; disabled; vendor preset: disabled)
       Active: active (running) since **; **min ago
     Main PID: 3929 (dotnet)
       CGroup: /system.slice/dnr.service
               └─3929 /usr/bin/dotnet /home/activetk/apps/dnr/dnr.dll
    
    vps systemd[1]: Unit dnr.service entered failed state.
    vps systemd[1]: dnr.service failed.
    vps systemd[1]: Started DarkNet Researcher.
    

    ちなみに、nohupでも代替できます。

    (echo -e "\n600\n" | nohup dotnet /home/activetk/apps/dnr/dnr.dll) > /home/activetk/apps/dnr/logs/hogehoge.log &
    

    一か月放置した結果

    発見したURL数 911,567
    既にアクセスしたURL数 24,480
    現在処理中のURL数 23,382
    エラーになったURL数 7,256
    → NotFound 0 (全て自動で削除)
    → BadGateway 2,146 (Tor側のエラー)

    一か月の間に約90万のURL(約2500ドメイン)を収集する事ができました。
    URL数としてはかなり多かったのですが、ドメイン数が少なかったのが残念です。ちなみにドメイン数は以下のようなSQLで取得できます。

    SELECT LEFT(OnionURL, 69), COUNT(LEFT(OnionURL, 69)) FROM darkneturls GROUP BY LEFT(OnionURL, 69) HAVING COUNT(LEFT(OnionURL, 69)) > 1;
    

    検索エンジン開発

    Onionドメインのリストができたので、ついでにPHPで検索エンジンを作ってみます。
    SQLiできそうですが、研究目的なのでお気になさらず。

    
        try {      $qu = "";
          $qb = "";
          $qc = "";
          $q = explode(" ", $_POST["search"]);
          foreach($q as $value)
            $qu .= "PageTitle like '%" . basename(str_replace("'", "", $value)) . "%' and ";
          if (isset($_POST["type"]) && $_POST["type"] == "and")
            foreach($q as $value)
              $qb .= "body like '%" . basename(str_replace("'", "", $value)) . "%' and ";
          else
            foreach($q as $value)
              $qb .= "body like '%" . basename(str_replace("'", "", $value)) . "%' or ";
          foreach($q as $value)
            $qc .= "OnionURL like 'http://" . basename(str_replace("'", "", $value)) . "%' or ";
          $limit = " limit 20";
          if (isset($_POST["limit"]) && $_POST["limit"] == "unlimited")
            $limit = " limit 2000";
          $selects = (new PDO(DSN, DB_USER, DB_PASS))->query(
            "(select OnionURL, PageTitle, LEFT(body, 170) from darkneturls where IsItDownOrNot = '0' and not AccessedDate = '' and " . $qu . "'1' = '1' order by AccessedDate desc" . $limit . ") UNION " .
            "(select OnionURL, PageTitle, LEFT(body, 170) from darkneturls where IsItDownOrNot = '0' and not AccessedDate = '' and " . $qb . "'1' = '1' order by AccessedDate desc" . $limit . ") UNION " .
            "(select OnionURL, PageTitle, LEFT(body, 170) from darkneturls where IsItDownOrNot = '0' and not AccessedDate = '' and " . $qc . "'1' = '2' order by AccessedDate desc" . $limit . ");"
          );
        } catch (PDOException $e) {
          die("データベースエラー(PDOエラー)が発生しました。しばらく時間を空けてから、再度検索してください。");
        }
    
        $result = array();
        foreach ($selects as $value) {
          $t++;
          if (empty($value["PageTitle"]) || strlen($value["PageTitle"]) < 3)
            $value["PageTitle"] = "(NoTitle)";
          $a = parse_url($value["OnionURL"]);
          if (isset($a["query"]) && !empty($a["query"])) $a["query"] = "?" . $a["query"];
          else $a["query"] = "";
          if (!isset($a["path"]))
            $a["path"] = "/";
          if (!$IsItTor)
            $value["OnionURL"] = "https://tor2web.activetk.jp/" . $a["host"] . $a["path"] . $a["query"];
          else
            $value["OnionURL"] = "http://" . $a["host"] . $a["path"] . $a["query"];
          $result[$value["OnionURL"]] = $value;
        }
    

    背景色は、いかにも「ダークウェブっぽい感じ」にしてみました。

    ほぼ自分専用に作ったOnion用検索エンジンですが、利用してみたい方はTwitter(@activetk5929)のDMまでお願いします(無償/寄付歓迎)。