В некоторый момент времени мне пришлось решить проблему, описанную в заголовке. Долго размышлял, писать ли очевидное, в итоге решил, что кому-то это может пригодиться.

Причина достаточно тривиальна — являясь автором Android-клиента к весьма нишевому сайту, я в то же время не вхожу ни в число его администраторов, ни в число соучредителей. Таки образом ни о каких решениях руководства сайта я не осведомлён до момента их фактического вступления в силу.

Не так давно на этот сайт началась DDoS-атака, и администрация включила DDoS-защиту от CloudFlare. Соответственно, приложение-клиент, использовавшее до этого стандартные механизмы авторизации через POST+Cookie, перестало авторизовывать пользователей. Общение с администрацией ни к чему не привело — «что мы можем сделать, лучше уж без мобильных клиентов, чем вообще никак».

Естественно, всё это начало отражаться на рейтингах и породило весьма нелестные отзывы.

Решением стал обход защиты от CloudFlare путём имитации поведения браузера на их странице. CloudFlare в конкретно этом случае использует хэш, ключ и случайный javascript-код, который браузер исполняет (вычисление нескольких арифметических действий, с виду выглядящих как обфусцированный мусор) и позже отсылает получившееся число вместе с хэшем и ключом на страницу проверки. Наша задача, таким образом — перехватить javascript-задание, решить его любым способом и спросить, правильна ли наша отгадка. Если да — получаем плюшку (куки cf_clearance). Если нет — получаем 503.

Покопавшись в поисковике, нашлась ровно одна ссылка, ведущая на проект, делающий нечто весьма похожее. Написанный на Python с использованием node.js или другого совместимого провайдера для PyExecJS. При всём моём уважении к Python, его использование в легковесном нишевом приложении было неоправданной роскошью, на интеграцию которой пришлось бы потратить много часов. Было принято стратегическое решение переписать код решателя на Java.

Некоторые примечания/неочевидности, возникшие во время написания кода:
  • В качестве JS-провайдера был выбран Mozilla Rhino, предоставляющий при отключённых оптимизациях совместимый с Dalvik-bytecode интерфейс.
  • UserAgent'ы, присущие автоматическим запросам, отклоняются с Error 503. Любые «Java/1.5.0_08», «libcurl-agent/1.0» и им подобные строки мгновенно отвергаются. Прежде чем хоть что-то пробовать, замаскируйтесь под UserAgent современного браузера.
  • В качестве Http-клиента использовалась реализация от Apache. Ей я больше доверяю, чем HttpURLConnection, которую продвигают разработчики Android, но это дело вкусов. Можете использовать любую совместимую реализацию, например, OkHttpClient
  • Важно: если вы хотите позже отображать какие-то данные c сайта в WebView, нужно учесть две вещи:
    1. У http-клиента должен быть в точности такой же UserAgent, что и у WebView (используйте settings.userAgentString у WebView)
    2. После получения cf_clearance-куки необходимо синхронизировать её с WebView (пример кода ниже)


Итоговый вариант ниже. Сколочен на скорую руку, но базовое представление о том, как всё работает, даёт.
    private final static Pattern OPERATION_PATTERN = Pattern.compile("setTimeout\\(function\\(\\)\\{\\s+(var t,r,a,f.+?\\r?\\n[\\s\\S]+?a\\.value =.+?)\\r?\\n");
    private final static Pattern PASS_PATTERN = Pattern.compile("name=\"pass\" value=\"(.+?)\"");
    private final static Pattern CHALLENGE_PATTERN = Pattern.compile("name=\"jschl_vc\" value=\"(\\w+)\"");

    abstract public HttpResponse getPage(URI url, HashMap<String, String> headers) throws IOException;
    abstract public CookieStore getCookieStore();

    public boolean cloudFlareSolve(String responseString) {
        // инициализируем Rhino
        Context rhino = Context.enter();
        try {
            String domain = "www.example.com";
            
            // CF ожидает ответа после некоторой задержки
            Thread.sleep(5000);
            
            // вытаскиваем арифметику
            Matcher operationSearch = OPERATION_PATTERN.matcher(responseString);
            Matcher challengeSearch = CHALLENGE_PATTERN.matcher(responseString);
            Matcher passSearch = PASS_PATTERN.matcher(responseString);
            if(!operationSearch.find() || !passSearch.find() || !challengeSearch.find())
                return false;
            
            String rawOperation = operationSearch.group(1); // операция
            String challengePass = passSearch.group(1); // ключ
            String challenge = challengeSearch.group(1); // хэш
            
            // вырезаем присвоение переменной
            String operation = rawOperation
                    .replaceAll("a\\.value =(.+?) \\+ .+?;", "$1")
                    .replaceAll("\\s{3,}[a-z](?: = |\\.).+", "");
            String js = operation.replace("\n", "");
            
            rhino.setOptimizationLevel(-1); // без этой строки rhino не запустится под Android
            Scriptable scope = rhino.initStandardObjects(); // инициализируем пространство исполнения

            // either do or die trying
            int result = ((Double) rhino.evaluateString(scope, js, "CloudFlare JS Challenge", 1, null)).intValue();
            String answer = String.valueOf(result + domain.length()); // ответ на javascript challenge

            final List<NameValuePair> params = new ArrayList<>(3);
            params.add(new BasicNameValuePair("jschl_vc", challenge));
            params.add(new BasicNameValuePair("pass", challengePass));
            params.add(new BasicNameValuePair("jschl_answer", answer));
            
            HashMap<String, String> headers = new HashMap<>(1);
            headers.put("Referer", "http://" + domain + "/"); // url страницы, с которой было произведено перенаправление
            
            String url = "http://" + domain + "/cdn-cgi/l/chk_jschl?" + URLEncodedUtils.format(params, "windows-1251");
            
            HttpResponse response = getPage(URI.create(url), headers);
            if(response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { // в ответе придёт страница, указанная в Referer
                response.getEntity().consumeContent(); // с контентом можно делать что угодно
                return true;
            }
        } catch (Exception e) {
            return false;
        } finally {
            Context.exit(); // выключаем Rhino
        }
        return false;
    }

    private void syncCookiesWithWebViews() {
        List<Cookie> cookies = getCookieStore().getCookies();
        CookieManager cookieManager = CookieManager.getInstance(); // CookieManager служит для синхронизации cookies между WebView
        for (Cookie cookie : cookies) {
            String cookieString = cookie.getName() + "=" + cookie.getValue() + "; domain=" + cookie.getDomain();
            cookieManager.setCookie("diary.ru", cookieString);
        }
    }


Код клиента опубликован под GPLv3, так что, скорее всего, о нём скоро прознает и CloudFlare, что приведёт к смене алгоритма. Тем не менее, я не приверженец принципа security by obscurity и задачу пускать мобильных пользователей до спада DDoS удалось решить.

Спасибо за внимание. Вопросы/замечания в комментарии.

Комментарии (5)


  1. TimsTims
    16.05.2015 20:17

    А почему бы например не завести отдельный поддомен, типа login.site.com, которому отключалась бы защита от DDoS.
    Или может в Cloudflare есть подходящая настройка для отключения своего метода кэширования?

    Неужели небыло другого выхода, кроме как имитация браузера, парсинг html+js, вычисление ключа итд.
    Не верю, что никто из Android-разработчиков не использует Cloudflare в качестве anti-ddos.


    1. Kanedias Автор
      16.05.2015 20:50
      +1

      А почему бы например не завести отдельный поддомен, типа login.site.com, которому отключалась бы защита от DDoS.
      Или может в Cloudflare есть подходящая настройка для отключения своего метода кэширования?

      Мне это неведомо, ведь дёргаю за ниточки и занимаюсь настройкой не я. Как и сказал, я зажат в определённые рамки. Не являясь администратором сайта и не имея почти никаких связей с руководством, повлиять на их решения я не могу. Так сказать, doing what I can with what I got.
      Неужели небыло другого выхода, кроме как имитация браузера, парсинг html+js, вычисление ключа итд.

      Поиск по тематике не выдал ничего подходящего. Почти все примеры предполагают, что вы контролируете и сайт, и клиентское приложение. Возможно, способ проще и есть, но я его не нашёл.


    1. DVamp1r3
      17.05.2015 02:11
      +2

      ботов перенаправят на этот же урл.


  1. tjomamokrenko
    17.05.2015 02:09

    Kanedias Напрашивается вопрос: Вы доброволец?


    1. Kanedias Автор
      17.05.2015 10:44

      Изначально был, но ведь, если подумать, никто не запрещает просить донат и продавать GPLv3-приложение.

      Не так ли?