Рушим капчу SilkRoad

в 17:00, , рубрики: silkroad, визуализация данных, информационная безопасность, капча, обход капчи

Рушим капчу SilkRoad

Silk Road, знаменитый черный рынок, был закрыт около года назад. До недавнего времени я думал, что он мертв. Это бы облегчило написание статьи, но не все так хорошо. Я только что прочитал о его возвращении в сеть.

Сейчас я хочу углубиться в код, который был написан мною несколько лет назад, чтобы разобрать механизм чтения капчи «старой» версии сайта.

Мотивация

Впервые я услышал о The Silk Road в статьях gwern'a. После подключения Tor я получил возможность увидеть этот сайт.

Моя первая мысль такова: здесь много интересных рыночных данных в реальном времени, к которым сложно получить доступ программно.

Неужели это Drug Wars с реальным обновлением цен?

Индексы, которые показывают MJ ▲0.21, COKE ▼3.53?

Через некоторое время я бы мог собрать историю изменения цен на все эти товары.

Я уже начал представлять себе графы, схемы и случаи Sr::Listing. Для начала нам нужно автоматизировать процес авторизации.

Весь код в этой статье будет представлен на языке Ruby, но API публиковать я не буду по двум причинам:

  1. Скорее всего, оно просто уже не работает;
  2. Оно никогда не было опрятно оформлено.

Tor

Silk Road был переделан в скрытый сервис TOR. Значит, наше API должно обращаться к Tor.

Vidalia открывает локально SOCKS5 прокси при запуске. Необходимо настроить клиент для верных HTTP запросов. К счастью, socksify gem позволяет нам это сделать. Этот трюк позволит нам преобразовывать запросы SOCKS в нашем приложении auto_configure.

require 'socksify'
require 'socksify/http'

module Sr

  class TorError < RuntimeError; end

  class Tor
    # Loads the torproject test page and tests for the success message.
    # Just to be sure.
    def self.test_tor?
      uri = 'https://check.torproject.org/?lang=en-US'
      begin
        page = Net::HTTP.get(URI.parse(uri))
        return true if page.match(/browser is configured to use Tor/i)
      rescue
        ;
      end

      false
    end


    # Our use case has the Tor SOCKS5 proxy running locally.  On unix, we use
    # `lsof` to see the ports `tor` is listening on.
    def self.find_tor_ports
      p = `lsof -i tcp | grep "^tor" | grep "(LISTEN)"`
      p.each_line.map do |l|
        m = l.match(/localhost:([0-9]+) /)
        m[1].to_i
      end
    end


    # Configures future connections to use the Tor proxy, or raises TorError
    def self.auto_configure!
      return true if @configured

      TCPSocket::socks_server = 'localhost'

      ports = find_tor_ports

      ports.each do |p|
        TCPSocket::socks_port = p
        if test_tor?
          @configured = true
          return true
        end
      end

      TCPSocket::socks_server = nil
      TCPSocket::socks_port = nil
      fail TorError, "SOCKS5 connection failed; ports: #{ports}"
    end
  end
end

Все готово. Мы создали довольно простой процесс.

Капча

Теперь мы переходим к теме статьи: обход капчи SilkRoad.

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

Я решил назвать проект успешным, если приложение будет распознавать текст с точностью до одной трети всех кодов.

В итоге получилось сделать нечто большее, чем планировалось.

Из-за того, что авторы Silk Road должны были развить в себе паранойю, они не могли использовать сервисы типа reCAPTCHA. Я не уверен, что их решение в итоге оказалось самописным, но давайте посмотрим на несколько примеров:

Рушим капчу SilkRoad
Рушим капчу SilkRoad
Рушим капчу SilkRoad
Рушим капчу SilkRoad
Рушим капчу SilkRoad

Есть несколько очевидных особенностей этого капчи:

  1. Стандартный формат: словарное слово, обрезанное до пяти символов, вместе с числом от 0 до 999;
  2. Шрифт никогда не меняется;
  3. Любой сивол может быть на любой позиции по оси Х;
  4. Любой сивол может быть повернут, но лишь на несколько градусов;
  5. На заднем плане наблюдается что-то типа спирали, не больно контрастирующее с текстом;
  6. Они все ужасные и розовые, что дает нам один канал цветовой информации.

Я написал Mechanize, который скачал 2,000 примеров капч с сайта с интервалом в две секунды. После я решил их вручную, называя в формате (текст).jpg. Это было очень даже грустно, поверьте.

Зато есть и плюсы: я получил много образцов для тестов в моем новом приложении.

Убираем фон

Самый, на мой взгляд, верный шаг для старта. На данном этапе я хотел получить изображения в оттенках серого, содержащих лишь только символы (желательно), а также отсеивание всех «шумов» изображения.

Используя Gimp, я поиграл с несколькими эффектами и несколькими их последовательностями. У меня были ошибки, но, в конечном счете, я получил вот это:

Оригинал:

Рушим капчу SilkRoad

Откорректированная:

Рушим капчу SilkRoad

Оттенки серого, 0.09:
Рушим капчу SilkRoad

Результат был получен следующим кодом для RMagick:

# Basic image processing gets us to a black and white image
# with most background removed
def remove_background(im)
  im = im.equalize
  im = im.threshold(Magick::MaxRGB * 0.09)

  # the above processing leaves a black border.  Remove it.
  im = im.trim '#000'
  im
end

Это освободило наше изображение от ненужных деталей, но все же остался «мусор» — маленькие черные точки между буквами. Давайте избавимся от них:

# Consider a pixel "black enough"?  In a grayscale sense.
def black?(p)
  return p.intensity == 0 || (Magick::MaxRGB.to_f / p.intensity) < 0.5
end


# True iff [x,y] is a valid pixel coordinate in the image
def in_bounds?(im, x, y)
  return x >= 0 && y >= 0 && x < im.columns && y < im.rows
end


# Returns new image with single-pixel "islands" removed,
#   see: Conway's game of life.
def despeckle(im)
  xydeltas = [[-1, -1],  [0, -1], [+1, -1],
              [-1,  0],           [+1,  0],
              [-1, +1],  [0, +1], [+1, +1]]

  j = im.dup
  j.each_pixel do |p, x, y|
    if black?(p)
      island = true

      xydeltas.each do |dx2, dy2|
        if in_bounds?(j, x + dx2, y + dy2) &&
            black?(j.pixel_color(x + dx2, y + dy2))
          island = false
          break
        end
      end

      im = im.color_point(x, y, '#fff') if island

    end
  end

  im
end

Итак, мы получили нечто подобное:
Рушим капчу SilkRoad

Замечательно.

Сегментация

Теперь я хочу разрезать нашу капчу на растровые изображения, чтобы каждое содержало по одному символу. Мы перемещаемся по картинке слева направо, выискивая белые отступы.

# returns true if column "x" is blank (non-black)
def blank_column?(im, x)
  (0 ... im.rows).each do |y|
    return false if black?(im.pixel_color(x, y))
  end

  true
end

# finds columns of white, and splits the image into characters, yielding each
def each_segmented_character(im)
  return enum_for(__method__, im) unless block_given?

  st = 0
  x  = 0
  while x < im.columns
    # Zoom over to the first non-blank column
    x += 1 while x < im.columns && blank_column?(im, x)

    # That's now our starting point.
    st = x

    # Zoom over to the next blank column, or end of the image.
    x += 1 while x < im.columns && (!blank_column?(im, x) || (x - st < 2))

    # slivers smaller than this can't possibly work: it's noise.
    if x - st >= 4
      # The crop/trim here also removes vertical whitespace, which puts the
      # resulting bitmap into its minimal bounding box.
      yield im.crop(st, 0, x - st, im.rows).trim('#fff')
    end
  end
end

Это обрезает нашу капчу на таки вот куски:
Рушим капчу SilkRoad

Далее каждый кусок превращается в отдельный квадратик с буквой, тем самым отделяясь от других.

Я выполнял данный код для множества заготовленных ранее капч, и вот что получилось в некоторых случаях:

Рушим капчу SilkRoad

Подумайте об этом как о гистограмме. Темные участки — это места, где алгоритм резал изображение. Вы можете наблюдать смещения…

Мы также видим то, как вращение символов влият на четкость полученного результата. Если бы символы вращались под большим углом, то наша задача существенно бы усложнилась.

Итак, каждый символ вполне возможно прочитать. Так как буквы взяты из английского словаря, то более часто используемые символы видны четче. Мы рассмотрим этот вопрос позднее.

Но не знал, что J используется настолько редко!

Нейронные сети для распознавания символов

Существует классный gem для Ruby под названием AI4R. Так как Ai4r::Positronic не всегда был доступен, я решил использовать нейронную сеть.

Вы начинаете с пустого массива битов. Вы обучаете его рисунками из известных решений:

  • «Такая модель представляет собой А.»,
  • «А эта модель тоже есть в А.»
  • «Эта модель встречается в В.»

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

Но есть и сложности. Чем больше у Вас символов и чем больше параметров для распознавания Вы указываете, тем дольше нужно тренировать алгоритм.

Я взял каждый символ в квадраты, сделал его размером 20x20 и применил монохромный эффект, и начал тренировки.

require 'ai4r'
require 'RMagick'

module Sr
  class Brain
    def initialize
      @keys  = *(('a'..'z').to_a + ('0'..'9').to_a)
      @ai = Ai4r::NeuralNetwork::Backpropagation.new([GRID_SIZE * GRID_SIZE,
                                                      @keys.size])
    end

    # Returns a flat array of 0 or 1 from the image data, suitable for
    # feeding into the neural network
    def to_data(image)
      # New image of our actual grid size, then paste it over
      padded = Magick::Image.new(GRID_SIZE, GRID_SIZE)
      padded = padded.composite(image,
                                Magick::NorthWestGravity,
                                Magick::MultiplyCompositeOp)

      padded.get_pixels(0, 0, padded.columns, padded.rows).map do |p|
        ImageProcessor.black?(p) ? 1 : 0
      end
    end

    # Feed this a positive example, e.g., train('a', image)
    def train(char, image)
      outputs = [0] * @keys.length
      outputs[ @keys.index(char) ] = 1.0
      @ai.train(to_data(image), outputs)
    end

    # Return all guesses, e.g., {'a' => 0.01, 'b' => '0.2', ...}
    def classify_all(image)
      results = @ai.eval(to_data(image))
      r = {}
      @keys.each.with_index do |v, i|
        r[v] = results[i]
      end
      r
    end

    # Returns best guess
    def classify(image)
      res = @ai.eval(to_data(image))
      @keys[res.index(res.max)]
    end
  end
end

Я изменил мой инструмент Mechanize для загрузки новых капч. На этот раз капчи решал алгоритм, после чего выполнял авторизацию в системе.

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

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

После нескольких часов тренировок процент успешно решенных задач составлял 90%.

К сожалению, длина капчи обычно равнялась восьми символам, поэтому, вероятность удачного решения была 0.90 ** 8, или 43%. Моя изначальная цель была достигнута, но хотелось большего.

Использование словаря и частоты использования букв

Временами наша сеть выдавала странных кандидатов решения, не соответствующих действительности. Что-то странное, что не соответствовало формату. Она распознавала символы независим и объединяла результат без дальнейшего контекста.

Но «словесная» часть капчи была не рандомными буквами, а именно частями реальных слов. Обрезанных слов из специального списка. Если бы у меня был лист, то можно бы было построить логическую цепочку рассуждений для улучшения результата распознавания символов. Вот так я генерировал свой список слов:

cat /usr/share/dict/words *.txt | tr A-Z a-z | grep -v '[^a-z]' 
  | cut -c1-5 | grep '...' | sort | uniq > dict5.txt

После я мог предположить, что dict5.txt содержал все возможные варианты, которые могла содержать капча.

# Returns the "word" and "number" part of a captcha separately.
# "word" takes the longest possible match
def split_word(s)
  s.match(/(.+?)?(d+)?z/.to_a.last(2) rescue [nil, nil]
end

def weird_word?(s)
  w, d = split_word(s)

  # nothing matched?
  return true if w.nil? || d.nil?

  # Digit in word part?, Too long?
  return true if w.match /d/ || w.size > 5

  # Too many digits?
  return true if d.size > 3

  # Yay
  return false
end

def in_dict?(w)
  return dict.bsearch { |p| p >= w } == w
end

Но как же исправить странные слова, которые отсутствуют в словаре?

Первой моей мыслью было заглянуть в список кандидатов системы, но дело было в другом. Скрипт выполнял некачественную сегментацию, увы

Давайте взглянем на эту интересную таблицу:

# a-z English text letter frequency, according to Wikipedia
LETTER_FREQ = {
  a: 0.08167, b: 0.01492, c: 0.02782, d: 0.04253, e: 0.12702, f: 0.02228,
  g: 0.02015, h: 0.06094, i: 0.06966, j: 0.00153, k: 0.00772, l: 0.04025,
  m: 0.02406, n: 0.06749, o: 0.07507, p: 0.01929, q: 0.00095, r: 0.05987,
  s: 0.06327, t: 0.09056, u: 0.02758, v: 0.00978, w: 0.02360, x: 0.00150,
  y: 0.01974, z: 0.00074
}

Заметили наше бедное и редко используемое J снова?

Peter Norvig написал интересную статью Как написать корректор произношения. У нас есть словарь и, предположительно, слово с опечаткой. Давайте это исправим:

# This finds every dictionary entry that is a single replacement away from
# word.  It returns in a clever priority: it tries to replace digits first,
# then the alphabet, in z..e (frequency) order. As we're just focusing on the
# "word" part, "9" is most definitely a mistake, and "z" is more likely a
# mistake than "e".

def edit1(word)
  # Inverse frequency, "zq...e"
  letter_freq = LETTER_FREQ.sort_by { |k, v| v }.map(&:first).join

  # Total replacement priority: 0..9zq..e
  replacement_priority = ('0'..'9').to_a.join + letter_freq

  # Generate splits, tagged with the priority, then sort them so
  # the splits on least-frequent english characters get processed first
  splits = word.each_char.with_index.map do |c, i|
    # Replace what we're looking for with a space
    w = word.dup;
    w[i] = ' '
    [replacement_priority.index(c), w]
  end
  splits.sort_by!{|k,v| k}.map!(&:last)

  # Keep up with results so we don't end up with duplicates
  yielded = []
  splits.each do |w|
    letter_freq.each_char do |c|
      candidate = w.sub(' ', c)
      next if yielded.include?(candidate)

      if in_dict?(candidate)
        yielded.push(candidate)
        yield candidate
      end
    end
  end
end

Большая хитрость заключается в замене. Используя таблицу частоты использования символов и список возможных слов, которые отличаются лишь одним символом от предложенных сетью вариантов, мы просто заменяем символ на нужный, чтобы исправить «опечатку».

Этот шаг увеличил процент успешных решений с 43% до 56%. Это заставило меня понять, что цель реально достигнуто.

Скоро будет опубликована статья о взломе новой (второй) капчи SilkRoad. Не пропустите!

За идею статьи спасибо ilusha_sergeevich

Автор: alexandfox

Источник

Поделиться

* - обязательные к заполнению поля