Ломаем текстовую капчу на примере VK или брутфорсинг до сих пор актуален

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

Немного о проблеме

Что мы знаем о капче? Капча — автоматизированный тест тьюринга, помогающий отсеивать подозрительные действия недобросовестных роботов от реальных людей. Но, к сожалению (или к счастью, смотря для кого), текстовая капча сильно устарела. Если еще 10 лет назад она была более-менее эффективным методом защиты от роботов, то сейчас ее может взломать любой желающий человек, более-менее разбирающийся в компьютере.

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

Результат работы обученной сети
Результат работы обученной сети

Дисклеймер:

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

Цель данной статьи — наглядно показать распространённую проблему текстовых капч.

Как всё начиналось

Было время, когда я писал один скрипт по спаму (альтернативному методу продвижения — рассылке) вк. Скрипт должен был уметь обходить капчу через сторониие сервисы (решают 1000 капч за 10-20 руб). И я подумал, почему бы не сохранять уже решенные капчи у себя на сервере? Таким образом я бесплатно накопил 150к примеров решенных капч (правда 5-10% из них были решены неправильно, но бОльшая часть датасета всё-таки валидна).

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

Выбираем нейросеть

Несмотря на то, что на дворе 2023 год, ничего более-менее адекватно работающего сходу я найти не смог. Большинство примеров предлагали перед распознаванием убрать шумы, разделить капчу ПОБУКВЕННО и перевести её в черно-белый формат (типо для облегчения работы).

Но в моём случае были некоторые проблемы:

  1. Как убрать шумы, если при переводе в black and white линии сливаются с буквам

  2. Как разделить капчу побуквенно, если заранее неизвестно, сколько букв она в себе содержит

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

RGB вариант — у букв и линий разный оттенок
RGB вариант — у букв и линий разный оттенок
BLACK and WHITE — буквы сливаются с линиями
BLACK and WHITE — буквы сливаются с линиями

Так я нашёл Tensorflow keras OCR model — CNN + RNN модель, реализующую фукнцию ошибки через CTC loss. В ней необязательно вообще что-то делать с картинками — достаточно минимально их обработать ( transpose(perm=[1, 0, 2]) ) и всё. Дальше модель разберётся сама.

Для ворчунов-профессионалов, которые скажут, что без грамотной обработки данных результат получится хуже, отвечаю: да, он получится хуже. Но он получится. И заработает. А это главное. В распознавании капч не нужна 100% вероятность, так что зачем сильно морочиться с preprocessing, если можно не париться?)

Подготавливаем данные

Убираем ошибки из датасета с помощью анализа входных данных

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

Статистика частоты вхождения букв в капчу
import random
import string
from pathlib import Path

data_dir_test = Path("../images/train")

symbols = {i: 0 for i in string.ascii_lowercase+string.digits}
lengths = [0]*100
for i in data_dir_test.glob("**/*"):
    name = i.name.split('-')[0].split('.')[0]
    for i in name:
        symbols[i] += 1
    lengths[len(name)] += 1
ans = []
#print char -> count
for i, item in sorted(symbols.items(), key=lambda e:-e[1]):
    print(item, i)
    if item > 0: ans.append(i)
#print length -> count
for i in filter(length, lambda e: e != 0):
  
print(ans)

Output:

67063 z
66807 s
66024 h
64136 q
61743 d
60674 v
25280 2
25208 7
25185 8
25107 x
25001 y
24993 5
24956 e
24784 a
24599 u
24492 4
24185 k
24094 n
22953 m
22318 c
18990 p
30 b
27 f
19 g
18 i
11 j
9 l
6 o
2 r
1 t
0 w
0 0
0 1
0 3
0 6
0 9
_________
LEN[3]=23
LEN[4]=61393
LEN[5]=62670
LEN[6]=14902
LEN[7]=14318
LEN[8]=4

['z', 's', 'h', 'q', 'd', 'v', '2', '7', '8', 'x', 'y', '5', 'e', 'a', 'u', '4', 'k', 'n', 'm', 'c', 'p']

Эти действия сразу помогают "почистить" примеры: видно, что буквы b, f, g, i, j, l, o, r, t заведомо ошибочные и что капч, состоящих из 3 и 8 символов, не существует.

Отлично, с символами определились

max_length = 7
characters = ['z', 's', 'h', 'q', 'd', 'v', '2', '7', '8', 'x', 
              'y', '5', 'e', 'a', 'u', '4', 'k', 'n', 'm', 'c', 'p']

Подготовка датасета

Перед дальнейшими действиями разобьем датасет с картинками на 2 папки: train и test. Папка test будет использоваться для тестирования готовой модели, train — для обучения (в процессе обучения датасет случайным образом разбивается на train и validate). Я разбил в пропорциях 1/15 ( у меня примеров много, поэтому 10к для теста будет достаточно; если у Вас датасет меньше, чем 20к, то стоит разбивать хотя бы 2/8 )

Теперь надо сделать преобработку картинки. В модели будет использоваться RGB float32 input:

img_width = 130 # ширина input'a
img_height = 50 # высота input'a
def encode_img(img):
    # Preprocessing
    #    We will use 3 channels input (in original code were 1 channel)
    if img_type == '*.jpeg':
        img = tf.io.decode_jpeg(img, channels=3, dct_method='INTEGER_ACCURATE')
    #        img = tf.image.rgb_to_grayscale(img)
    else:
        img = tf.io.decode_png(img, channels=3)

    # Convert to float32 in [0, 1] range
    img = tf.image.convert_image_dtype(img, tf.float32)
    # Resize to the desired size
    img = tf.image.resize(img, [img_height, img_width])
    # Transpose the image because we want the time
    # dimension to correspond to the width of the image.
    img = tf.transpose(img, perm=[1, 0, 2])
    return img
  def encode_single_sample(img_path, label):
    # Read image
    img = tf.io.read_file(img_path)
    # Preprocessing
    img = encode_img(img)
    # Map the characters in label to numbers
    label = char_to_num(tf.strings.unicode_split(label, input_encoding="UTF-8"))
    # Return a dict as our model is expecting two inputs
    return {"image": img, "label": label}

И запихать это всё в 2 датасета: один для обучения, другой — для проверки работоспособности модели (в процессе обучения)

def split_data(images: 'list', labels: 'list', train_size=0.9, shuffle=True):
    size = len(images)
    # 1. Make an indices array and shuffle it, if required
    indices = np.arange(size)
    if shuffle:
        np.random.shuffle(indices)
    # 2. Get the size of training samples
    train_samples = int(size * train_size)
    # 3. Split data into training and validation sets
    x_train, y_train = images[indices[:train_samples]], labels[indices[:train_samples]]
    x_valid, y_valid = images[indices[train_samples:]], labels[indices[train_samples:]]
    return x_train, x_valid, y_train, y_valid

x_train, x_valid, y_train, y_valid = split_data(np.array(images), np.array(labels))
train_dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train))
train_dataset = (
    train_dataset.map(encode_single_sample, num_parallel_calls=tf.data.AUTOTUNE)
        .batch(batch_size)
        .prefetch(buffer_size=tf.data.AUTOTUNE)
)

validation_dataset = tf.data.Dataset.from_tensor_slices((x_valid, y_valid))
validation_dataset = (
    validation_dataset.map(encode_single_sample, num_parallel_calls=tf.data.AUTOTUNE)
        .batch(batch_size)
        .prefetch(buffer_size=tf.data.AUTOTUNE)
)

Создаём и обучаем модель

Пришло время для самой модели. Она будет состоять из 2х блоков Conv2D, RNN, (output), CTC loss Layer (для ошибки)

Генерируем модель (многа сложных букф)
# Loss function
class CTCLayer(layers.Layer):
    def __init__(self, name=None):
        super().__init__(name=name)
        self.loss_fn = keras.backend.ctc_batch_cost

    def call(self, y_true, y_pred, **kwargs):
        # Compute the training-time loss value and add it
        # to the layer using `self.add_loss()`.
        batch_len = tf.cast(tf.shape(y_true)[0], dtype="int64")
        input_length = tf.cast(tf.shape(y_pred)[1], dtype="int64")
        label_length = tf.cast(tf.shape(y_true)[1], dtype="int64")

        input_length = input_length * tf.ones(shape=(batch_len, 1), dtype="int64")
        label_length = label_length * tf.ones(shape=(batch_len, 1), dtype="int64")

        loss = self.loss_fn(y_true, y_pred, input_length, label_length)
        self.add_loss(loss)

        # At test time, just return the computed predictions
        return y_pred
def build_model():
    # Inputs to the model
    input_img = layers.Input(
        # Lets use RGB input instead of black and white
        shape=(img_width, img_height, 3), name="image", dtype="float32"
    )
    labels = layers.Input(name="label", shape=(None,), dtype="float32")

    # First conv block
    x = layers.Conv2D(
        32,
        (3, 3),
        activation="relu",
        kernel_initializer="he_normal",
        padding="same",
        name="Conv1",
    )(input_img)
    x = layers.MaxPooling2D((2, 2), name="pool1")(x)

    # Second conv block
    x = layers.Conv2D(
        64,
        (3, 3),
        activation="relu",
        kernel_initializer="he_normal",
        padding="same",
        name="Conv2",
    )(x)
    x = layers.MaxPooling2D((2, 2), name="pool2")(x)

    # We have used two max pool with pool size and strides 2.
    # Hence, downsampled feature maps are 4x smaller. The number of
    # filters in the last layer is 64. Reshape accordingly before
    # passing the output to the RNN part of the model
    new_shape = ((img_width // 4), (img_height // 4) * 64)
    x = layers.Reshape(target_shape=new_shape, name="reshape")(x)
    x = layers.Dense(64, activation="relu", name="dense1")(x)
    x = layers.Dropout(0.2)(x)

    # RNNs
    x = layers.Bidirectional(layers.LSTM(128, return_sequences=True, dropout=0.25))(x)
    x = layers.Bidirectional(layers.LSTM(64, return_sequences=True, dropout=0.25))(x)

    # Output layer
    x = layers.Dense(
        len(char_to_num.get_vocabulary()) + 1, activation="softmax", name="dense2"
    )(x)

    # Add CTC layer for calculating CTC loss at each step
    output = CTCLayer(name="ctc_loss")(labels, x)

    # Define the model
    model = keras.models.Model(
        inputs=[input_img, labels], outputs=output, name="ocr_model_v1"
    )
    # Optimizer
    opt = keras.optimizers.Adam()
    # Compile the model and return
    model.compile(optimizer=opt)
    return model
model = build_model()

Добавляем защиту от переобучения

early_stopping = keras.callbacks.EarlyStopping(
    monitor="val_loss", patience=early_stopping_patience, restore_best_weights=True
)

..... Запускаем! .....

history = model.fit(
    train_dataset,
    validation_data=validation_dataset,
    epochs=100,
    callbacks=[early_stopping],
)
Процесс обучения модели
Процесс обучения модели

Обучалась модель у меня на процессоре (AMD RYZEN 5 3600) ~ 10 часов, на видеокарте (GTX 1070 TI) ~ 3 часа.

Если вдруг захотите обучать на видеокарте, то будьте готовы потратить 2-3 часа на уставку CUDA, tensorflow-GPU, и кучу другого гемороя (например, conda, — без неё на windows с обучением всё очень грустно)

Тестируем

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

Код тестов

Подгружаем нашу модель

model: Functional = keras.models.load_model(MODEL_FNAME)

Загружаем тестовый датасет

images = list(map(str, [i for i in data_dir_test.glob(img_type)]))
images = sorted(images, key=lambda *h: random.random())
labels = [
    img.split(os.path.sep)[-1].split('-')[0].split('.jpeg')[0].ljust(max_length)[:max_length]
    for img in images
]

test_dataset = tf.data.Dataset.from_tensor_slices((numpy.array(images), numpy.array(labels)))
test_dataset = (
    test_dataset.map(encode_single_sample, num_parallel_calls=tf.data.AUTOTUNE)
    .batch(batch_size)
    .prefetch(buffer_size=tf.data.AUTOTUNE)
)

Визуализация (по желанию):

def visualize():
    _, ax = plt.subplots(4, 4, figsize=(15, 5))
    for i in range(len(pred_texts)):
        img = (image[i, :, :, :] * 255).numpy().transpose([1, 0, 2]).astype(np.uint8)
        title = f" {pred_texts[i][0]}{pred_texts[i][1]:.3%} {'ok' if success else 'wrong'}"
        ax[i // 4, i % 4].imshow(img)
        ax[i // 4, i % 4].set_title(title)
        ax[i // 4, i % 4].axis("off")
    plt.show()

И, соответственно, тесты:

good = 0
total = 0

for i in test_dataset.take(1000):
    label, image = i['label'], i['image']
    preds = model.call(image)
    pred_texts = decode_batch_predictions(preds)[0]
    orig_texts = [tf.strings.reduce_join(num_to_char(l)).numpy().decode("utf-8").replace('[UNK]', '') for l in label]
    if need_visualization and total == 0:
        visualize()
    for i in range(len(pred_texts)):
        success = pred_texts[i] == orig_texts[i]
        if success:
            good += 1
        total += 1
    print(f"Progress {total/len(images):.2%}[{total}/{len(images)}]...", end='\r')
print(f"Success: {(good / total):.2%}\n")
Результат тестов
Результат тестов

У модель выдавала правильный ответ с вероятностью 94.05%.

Ура?

В целом, можно сказать, что модель готова. НО! Тут, как всегда, не без сюрпризов. А сюрпризы в данном случае: 2ГБ библиотеки Tensorflow, 10-15 секунд на её import, долгое время работы и т.п.

В общем, после длительных поисков, было решено переходить на формат onnx. А что, стильно, модно, молодёжно, подумал я. Но, естественно, с 1-го раза ничего не заработало

Конвертируем модель в ONNX

Сначала я попытался использовать keras2onnx, но он у меня не заработал. Погуглив ошибку, я пришёл к выводу, что во всём виновата windows (о великие линукса), перешел на виртуалку, попробовал на ней и.... Опять ничего не заработало (после 4х часов попыток заставить что-либо двигаться).

Далее на очередь пришёл tf2onnx. Но заработал он у меня только с 3-го раза: в первый раз, после установки, оказалось, что python3.10 не поддерживается, затем, на 2й раз — что я обучал модель на слишком новой версии tensorflow, которую tf2onnx пока не поддерживает (вот зараза).

В результате у меня-таки получилось всё провернуть со следующими версиями:

python3.9
tensorflow==2.9.1
tf2onnx==1.11.1
onnxruntime==1.9.0
onnxconverter-common==1.10.0

Ну и сам скрипт конвертации:

import pathlib, tf2onnx.convert, sys
from tools import MODEL_FNAME

OUTPUT_ONNX = "../out.model.onnx"

dir = pathlib.Path(__file__).parent
sys.argv.append("--saved-model")
sys.argv.append(str(dir / MODEL_FNAME))
sys.argv.append("--output")
sys.argv.append(str(dir / OUTPUT_ONNX))
tf2onnx.convert.main()

UPDATE: вроде как сейчас уже есть поддержка python3.10, но проверять я это не хочу)

Переписываем код с tensorflow на numpy

Теперь, после конвертации в ONNX, возникла необходимость переписать препроцессинг картинок с tensorflow на великий numpy (весит меньше, работает шустрее).

В случае препроцессинга это не составило особого труда:

def decode_img(data_bytes: bytes):
  # same actions, as for tensorflow
  img = cv2.imdecode(np.asarray(bytearray(data_bytes), dtype=np.uint8), -1)
  img: "np.ndarray" = img.astype(np.float32) / 255.
  if img.shape != (img_height, img_width, 3):
      cv2.resize(img, (img_width, img_height))
  img = img.transpose([1, 0, 2])
  #  Creating tensor ( adding 4d dimension )
  img = np.array([img])
  return img
Немного о INTEGER_ACCURATE

Тестируем и... Процент верных ответов снижается на 10%. Это стало очень неприятно, и, как оказалось, всё дело в методе преобрабоки картинки.

Существует много методов декодирования JPEG картинок, и, естественно, tensorflow и numpy по умолчанию используют разные. Чтобы они были наиболее cхожи, стоит указать параметр dct_method='INTEGER_ACCURATE'.

img = tf.io.decode_jpeg(img, channels=3, dct_method='INTEGER_ACCURATE')

Немного официальной документации:

dct_method - String specifying a hint about the algorithm used for decompression. Defaults to "" which maps to a system-specific default. Currently valid values are ["INTEGER_FAST", "INTEGER_ACCURATE"]. The hint may be ignored (e.g., the internal jpeg library changes to a version that does not have that specific option.)

Не повторяйте моих ошибок :)

А вот в случае декодирования результа пришлось вникнуть в Beam Search Decoding for ctc alorithm. Если вкратце, то на выход мы получаем 2-мерный массив длиной в 32 элемента, где каждый элемент — массив вероятностей на этом месте каждой буквы.

Код декодирования результата:

def get_result(pred):
    """CTC decoder of the output tensor
    https://distill.pub/2017/ctc/
    https://en.wikipedia.org/wiki/Connectionist_temporal_classification
    :return string, float
    """
    accuracy = 1
    last = None
    ans = []
    # pred - 3d tensor, we need 2d array - first element
    for item in pred[0]:
        # get index of element with max accuracy
        char_ind = item.argmax()
        # ignore duplicates and special characters
        if char_ind != last and char_ind != 0 and char_ind != len(characters)+1:
            # this element is a character - append it to answer
            ans.append(characters[char_ind - 1])
            # Get accuracy for current character and 
            # multiply global accuracy by it
            accuracy *= item[char_ind]
        last = char_ind

    answ = "".join(ans)[:max_length]
    return answ, accuracy

Тестируем ONNX модель

Настало время финальной проверки: необходимо удостовериться, что мы всё сделали правильно. То есть результат работы ONNX модели не должен сильно отличаться от оригинальной. Также необходимо прогнать тесты на "реальных" капчах (генерируемых сервером) и посмотреть, насколько наша модель с ними справляется (а то вдруг всё было зря

Источник: https://habr.com/ru/post/673440/


Интересные статьи

Интересные статьи

Стать частью экосистемы для технологических предпринимателей — возможность не только повысить свой статус и получить грантовую поддержку, но и значительно сократить издержки на разработке и производст...
Привет, Хабр! На связи Юрий Кацер, эксперт ML и анализу данных в промышленности, а также руководитель направления предиктивной аналитики в компании «Цифрум» Госкорпорации “Росатом”. Недавно я выступил...
Возможность интеграции с «1С» — это ключевое преимущество «1С-Битрикс» для всех, кто профессионально занимается продажами в интернете, особенно для масштабных интернет-магазинов.
Компании растут и меняются. Если для небольшого бизнеса легко прогнозировать последствия любых изменений, то у крупного для такого предвидения — необходимо изучение деталей.