Как стать автором
Обновить
437.82

Битфлипаем зашифрованный JSON, не зная структуры: разбор демозадачи с T-CTF 2025

Уровень сложностиСредний
Время на прочтение19 мин
Количество просмотров8.6K

Проводим третий T-CTF для ИТ-специалистов, которые хорошо разбираются в коде и кибербезопасности. Соревнования пройдут онлайн и офлайн 19—20 апреля, так что пока есть время подготовиться. В статье расскажем, что нового будет в этом году, и разберем одно демозадание.

Если вы уже ко всему готовы, переходите на сайт мероприятия. Там можно узнать подробности и зарегистрироваться. Новичков в CTF и тех, кто хочет узнать решение интересных задач, приглашаем под кат.

Что такое T-CTF

CTF — соревнования по спортивному хакингу: как олимпиадное программирование, но в информационной безопасности. Команды получают набор заданий на криптографию, анализ скомпилированного кода, веб-уязвимости и не только — на все те направления, с которыми работают профессионалы-безопасники. 

Мы сделали соревнования в двух лигах — Разработки и Безопасности. В Лиге безопасности ждем опытных игроков и профи в информационной безопасности, а в Лиге разработки — опытных инженеров, SRE и QA, аналитиков и других ИТ-специалистов.

В этом году участники попадут в Капибаровск — виртуальный город, где живут капибары. У животных лапки: они ничего не понимают в коде и вся их инфраструктура постоянно выходит из строя. На Капибарже постоянно путают грузы, а в театре-капаре сбой в системе, из-за которого никто не может попасть на шоу. 

Капибарам не справиться без помощи ИТ-комьюнити, поэтому мы зовем на T-CTF всех, кто готов примерить на себя белую шляпу!

Как все устроено

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

Соревнования проходят 36 часов, онлайн и офлайн в ИТ-хабах Т-Банка. Можно решать задачи все 36 часов или делать перерывы: пить кофе, знакомиться и общаться с другими игроками.

Максим Красножён, старший разработчик в Т-Банк Выгоде

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

Федор Красильников, инженер в группе надежности Product & Business Platforms

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

Поучаствовать офлайн можно в шести городах: Москве, Санкт-Петербурге, Казани, Иннополисе, Екатеринбурге и Новосибирске. На площадках будет все самое необходимое: рабочее место для прохождения соревнований, зоны отдыха, вкусная еда и напитки. 

На T-CTF вас будут ждать задания, связанные с безопасностью веба и приложений, инфраструктурой и криптографией. Лучшие команды получат призы — деньги и капибару. 

В каждой лиге — по три призовых места с денежными призами, которые делятся на команду. В Лиге Разработки максимальный приз — 360 000 ₽, в Лиге Безопасности — 420 000 ₽.

Разбор демозадачи «Капибиржа»

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

Но администраторы биржи теперь тоже не могут войти в систему, чтобы разморозить активы. Помогите восстановить доступ к деньгам и приведите биржу в порядок, пока котировки $CAPY не обвалились совсем из-за поднявшейся паники.
https://t-capybit-kdot8z7j.spbctf.org/ 

Осматриваемся

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

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

Осматриваемся на сайте биржи
Осматриваемся на сайте биржи

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

В прошлых разборах демозаданий на безопасность веб-приложений (2023, 2024) мы использовали Burp Suite для работы с HTTP-запросами. Это удобный инструмент, чтобы исследовать веб-аппки, и если раньше вы с ним не работали, посмотрите прошлые разборы. В этот же раз нам хватит и консоли браузера, будем использовать ее.

На вкладке Network в консоли видим, что происходит, когда мы регистрируемся: на сервер в поле username улетает введенное имя пользователя, а в ответ сервер ставит нам кукис с названием session и длинной строчкой внутри.

HTTP-запрос и ответ при регистрации
HTTP-запрос и ответ при регистрации

Экспериментируем с кукой

По кукису sessionприложение нас узнает. Мы ввели логин testuser123 и получили кукис28123600894ec0052e3abfa0039b98ec1efec27e389dcf65765bc4a5792bc8f7a1ba2a6b431f0c7d3ae93c4924a7e804f3e9011fb96c68d141c53dd904c381f40e05e93fa4180dc9a5dcc18381df5cd901c586d1075ae111db0bdff1f7e12477f6ea7ac9e6fe59152bd0d2b200680ccc88

Строчка целиком состоит из цифр 0–9 и букв a–f, значит, это хексы — байты, записанные в виде текста по два символа на байт, от 00 до ff.

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

Декодировать хексы удобно, например, в интерактивной консоли языка Python:

In [1]: b''.fromhex('28123600894ec0052e3abfa0039b98ec1efec27e389dcf65765bc4a5792bc8
   ...: f7a1ba2a6b431f0c7d3ae93c4924a7e804f3e9011fb96c68d141c53dd904c381f40e05e93fa
   ...: 4180dc9a5dcc18381df5cd901c586d1075ae111db0bdff1f7e12477f6ea7ac9e6fe59152bd0
   ...: d2b200680ccc88')
Out[1]: b'(\x126\x00\x89N\xc0\x05.:\xbf\xa0\x03\x9b\x98\xec\x1e\xfe\xc2~8\x9d\xcfev
[\xc4\xa5y+\xc8\xf7\xa1\xba*kC\x1f\x0c}:\xe9<I$\xa7\xe8\x04\xf3\xe9\x01\x1f\xb9lh\x
d1A\xc5=\xd9\x04\xc3\x81\xf4\x0e\x05\xe9?\xa4\x18\r\xc9\xa5\xdc\xc1\x83\x81\xdf\\\x
d9\x01\xc5\x86\xd1\x07Z\xe1\x11\xdb\x0b\xdf\xf1\xf7\xe1$w\xf6\xeaz\xc9\xe6\xfeY\x15
+\xd0\xd2\xb2\x00h\x0c\xcc\x88'

Мы получили нечитаемую мешанину. Большинство байтов вообще не обозначают символ текста, такие байты Python показывает нам снова в виде их хекс-значений: например, в начале строчки хексы 12 раскодировались в байт \x12. И раз в хексах мусор, данные внутри кукиса зашифрованы либо сжаты — это два преобразования, которые превращают данные в трэш.

Попробуем регистрироваться под разными логинами, чтобы собрать побольше информации:

username

Кукис session

testuser123

28123600894ec0052e3abfa0039b98ec1efec27e389dcf65765bc4a5792bc8f7a1ba2a6b431f0c7d3ae93c4924a7e804f3e9011fb96c68d141c53dd904c381f40e05e93fa4180dc9a5dcc18381df5cd901c586d1075ae111db0bdff1f7e12477f6ea7ac9e6fe59152bd0d2b200680ccc88

testuser234

28123600894ec0052e3abfa0039b98ec1efec17f3f9dcf65765bc4a5792bc8f7a1ba2a6b431f0c7d3ae93c4924a7e804f3e9011fb96c68d141c53dd904c381f40e05e93fa4180dc9a5dcc18381df5cd901c586d1075ae111db0bdff1f7e12477f6ea7ac9e6fe59152bd0d2b200680ccc88

testuser234567890

28123600894ec0052e3abfa0039b98ec1efec17f3f8ad5726c0380f3276288e3a1ab2b201b4a033a3ba46a0c7dbfba49eaff4657bb623d8047db7a9456808cfb0e46a17ef85748c9ea9287ddd48e5dd21195c982074df056cd0b89e7e7f46925a0e37ad7f3ae17586ccdc7fc5f3b0cc294afb1e428170c

Находить различия удобно, переключаясь по вкладкам
Находить различия удобно, переключаясь по вкладкам

Какие закономерности можно увидеть:

  1. Когда мы поменяли в логине три символа (123234), в куке поменялись три байта (шесть хексов: c27e38c17f3f), а все остальные байты остались прежними.

  2. Каждый байт поменялся несильно: c2 → c1, 7e → 7f, 38 → 3f.

  3. Когда мы увеличили логин на +6 символов, кукис вырос на 6 байтов (12 хексов), при этом начало всей куки осталось тем же, но конец полностью изменился.

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

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

Вот наша новая кука с нулями в несколько байтов, скормим ее приложению: 28123600894ec0052e3abfa0039b98ec1efec0703080d070600080f3276288e3a1ab2b201b4a033a3ba46a0c7dbfba49eaff4657bb623d8047db7a9456808cfb0e46a17ef85748c9ea9287ddd48e5dd21195c982074df056cd0b89e7e7f46925a0e37ad7f3ae17586ccdc7fc5f3b0cc294afb1e428170c

Подставляем подменную куку на вкладке ApplicationCookies и смотрим, что произошло:

Наш логин поменялся с testuser234567890 на testuser3<;?354:0 — мы смогли, вслепую изменив несколько мусорных непонятных байтов, повлиять на то, под каким логином нас узнает приложение!

Пора разбираться, как это работает под капотом и как с помощью этого поднять привилегии.

XOR, поточное шифрование и бит-флиппинг

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

В алгоритмах шифрования часто используется операция XOR — она складывает два бита (A^B), получая бит на выходе (C):

A   B     C
0 ^ 0  => 0
0 ^ 1  => 1
1 ^ 0  => 1
1 ^ 1  => 0

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

  1. Работает на уровне отдельных битов — можно зашифровать сколь угодно маленькое или большое количество данных.

  2. Не дает никакой информации без знания ключа шифрования — зашифрованный бит «1» мог получиться при XOR бита данных «1» с битом ключа «0», а мог — при данных «0» с ключом «1». Без знания ключа не получится с немного большей вероятностью предположить, был бит данных 0 или 1. Операцию A^B можно также представить как инструкцию, какие биты в A поменять, а какие оставить: там, где бит у B «1» — переключаем бит у A, где бит у B «0» — не меняем.

    A 01001000 01100101 01101100 01101100 01101111 # "Hello"
    ^ B 01101011 00110011 01111001 00100001 00100001 # "k3y!!"
    => 00100011 01010110 00010101 01001101 01001110 # "#V MN"

  3. XOR обратим — чтобы получить исходные данные, достаточно просто поксорить еще раз на те же секретные биты — переключить биты данных в тех же местах.

    A 00100011 01010110 00010101 01001101 01001110 # "#V MN"
    ^ B 01101011 00110011 01111001 00100001 00100001 # "k3y!!"
    => 01001000 01100101 01101100 01101100 01101111 # "Hello"

Целый класс алгоритмов шифрования сводится к этому простому действию: данные ксорятся на секретный поток битов. Это все поточные шифры (RC4, Chacha20) и блочные шифры в определенных режимах (AES-CTR, AES-OFB). Они работают одинаково: из секретного ключа генерируется секретный поток бит, этими битами накрываются данные.

Мы не знаем ключ шифрования, который используется в бирже Capybit, и видим только шифрованное сообщение — для нас ситуация выглядит так:

userinfo ******** ******** ******** ******** ******** ... # *****
^ keystream ???????? ???????? ???????? ???????? ???????? ... # ?????
=> cookie 00101000 00010010 00110110 00000000 10001001 ... # 28 12 36 00 89
↑↑↑ это мы видим в кукисе session
При заходе на сайт, приложение снова расшифровывает кукис:
cookie 00101000 00010010 00110110 00000000 10001001 ... # 28 12 36 00 89
^ keystream ???????? ???????? ???????? ???????? ???????? ... # ?????
=> userinfo ******** ******** ******** ******** ******** ... # *****

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

Но целостность данных не защищена: мы можем менять (флипать) биты в шифрованном кукисе — и ровно этот же бит будет флипаться в расшифровываемых данных!

original 00101000 00010010 00110110 00000000 10001001 ... # 28 12 36 00 89
flipped 00101000 00010010 00110110 00000100 10001001 ... # 28 12 36 04 89
^ keystream ???????? ???????? ???????? ???????? ???????? ... # ?????
=> userinfo ******** ******** ******** *****%** ******** ... # ***%*

Именно это произошло, когда мы изменили в куке c17f3f8ad5726c0380c0703080d070600080 и получили смену логина testuser234567890testuser3<;?354:0 :

userinfo .. 00110010 00110011 00110100 00110101 00110110 .. # user"23456"...
^ keystream .. ???????? ???????? ???????? ???????? ???????? .. # ?????
=> cookie .. 11000001 01111111 00111111 10001010 11010101 .. # c1 7f 3f 8a d5
Изменили в куке некоторые символы на нули:
flipped .. 11000000 01110000 00110000 10000000 11010000 .. # c0 70 30 80 d0
^ keystream .. ???????? ???????? ???????? ???????? ???????? .. # ?????
=> userinfo .. 00110011 00111100 00111011 00111111 00110011 .. # user"3<;?3"...
Эти же биты ↑ флипнулись в ↑↑↑ данных ↑ ↑ ↑

Мы все еще не можем расшифровать все данные в куке, но по реакции приложения на наш бит-флиппинг получили полезную информацию. Байт в кукисе, который мы поменяли с c1 на c0, был символом 2 из логина user234567890 в оригинальных нешифрованных данных и стал3, когда мы флипнули ему младший бит. Мы знаем, где в кукисе логин — если бы нам нужно было подделать логин, мы бы знали, у каких байтов флипать биты, чтобы на него влиять.

Но нам нужно подделать привилегии для доступа к управлению активами:

Бит-флипаем кукис

Кука большая, в ней явно хранится не только логин — стоит попробовать пофлипать биты в остальных ее байтах и посмотреть, что будет.

Сделаем это скриптом на Python, который попробует поменять младший бит у каждого байта кукиса и сходит с получившейся кукой на ручку управления активами:

#!/usr/bin/python3
import requests
sess = requests.Session()

cookie = bytearray(b''.fromhex("28123600894ec0052e3abfa0039b98ec1efec27e389dcf65765bc4a5792bc8f7a1ba2a6b431f0c7d3ae93c4924a7e804f3e9011fb96c68d141c53dd904c381f40e05e93fa4180dc9a5dcc18381df5cd901c586d1075ae111db0bdff1f7e12477f6ea7ac9e6fe59152bd0d2b200680ccc88"))

for i in range(len(cookie)):
    print(f'Position {i:3d}\t', end='')
    flipped = cookie[:]
    flipped[i] ^= 0b00000001  # флипаем у i-го байта младший бит — ксором на байт, у которого 1 только в младшем бите
    res = sess.get("https://t-capybit-kdot8z7j.spbctf.org/funds", cookies={"session": flipped.hex()})
    print(res.text)

Запускаем и смотрим на реакцию приложения:

root@vosus:/mnt/f# ./flipper.py
Position   0  → JSON parsing error: Expecting value: line 1 column 1 (char 0)
Position   1    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
Position   2    rule role = 'root' not satisfied, your role = 'user'      ↑ ↑ ↑ ↑ ↑
Position   3    rule role = 'root' not satisfied, your role = 'user'
Position   4    rule role = 'root' not satisfied, your role = 'user'
Position   5    rule role = 'root' not satisfied, your role = 'user'
Position   6  → JSON parsing error: Expecting ':' delimiter: line 1 column 11 (char 10)
Position   7    JSON parsing error: Expecting ':' delimiter: line 1 column 8 (char 7)
Position   8    JSON parsing error: Expecting value: line 1 column 9 (char 8)
Position   9    JSON parsing error: Expecting value: line 1 column 10 (char 9)
Position  10    rule role = 'root' not satisfied, your role = 'user'
<....>
Position  43    rule role = 'root' not satisfied, your role is not set
Position  44    JSON parsing error: Expecting ':' delimiter: line 1 column 49 (char 48)
Position  45    JSON parsing error: Expecting ':' delimiter: line 1 column 46 (char 45)
Position  46    JSON parsing error: Expecting value: line 1 column 47 (char 46)
Position  47    JSON parsing error: Expecting value: line 1 column 48 (char 47)
Position  48    rule role = 'root' not satisfied, your role = 'tser' ← !
Position  49    rule role = 'root' not satisfied, your role = 'urer' ← !
Position  50    rule role = 'root' not satisfied, your role = 'usdr' ← !
Position  51    rule role = 'root' not satisfied, your role = 'uses' ← !
Position  52  → JSON parsing error: Expecting ',' delimiter: line 1 column 57 (char 56)
Position  53    JSON parsing error: Expecting ',' delimiter: line 1 column 54 (char 53)
Position  54    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 55 (char 54)
Position  55    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 56 (char 55)
Position  56    rule role = 'root' not satisfied, your role = 'user'
<....>

Богатый набор реакций! Что мы узнали:

  1. В оригинальных данных лежит JSON.

  2. На позиции [1] двойная кавычка ", на [6] :, на [52] , — похоже, зашифрован JSON-объект вида {"field":"value","field":1234}.

  3. На позициях [48…51] лежит наша роль user! Мы прошлись по ней флипами младшего бита: tser urer usdr uses, а приложение ругается, что у нас должна быть роль root.

Флипнем биты на позициях [48…51] так, чтобы из user там стал root. Можно посчитать вручную, какие биты для этого нужно флипнуть:

cookie 11110011 11101001 00000001 00011111 # f3 e9 01 1f
original 01110101 01110011 01100101 01110010 # "user"
Сравниваем оригинал с root и смотрим, какие биты менять:
"root": 01110010 01101111 01101111 01110100 # "root"
Меняем эти биты в кукисе:
flipped 11110100 11110101 00001011 00011001 # f4 f5 0b 19

Можно вычислить проще — поксорить то, что было (user), с тем, что нужно (root):

In [2]: xor = lambda A, B: bytes([a^b for a, b in zip(A, B)])  # вспомогательная функция, чтобы поксорить две строчки байт
   ...: cookie = b''.fromhex("f3e9011f")  # фрагмент изначального кукиса
   ...: flip = xor(b'user', b'root')  # ксорим "user" на "root" — получаем битовую разницу между ними
   ...: flipped = xor(cookie, flip)  # ксорим шифрованные данные на эту разницу — флипаем в них соответствующие биты
   ...: flipped.hex()
Out[2]: 'f4f50b19'  # получили те же модифицированные данные, что до этого руками

Меняем в куке f3e9011ff4f50b19 и смотрим на реакцию приложения:

Отлично! Мы прошли проверку роли, успешно бит-флипнув себе userroot. Теперь приложение проверяет следующие условия. Зарядим новую куку с рутовой ролью в скрипт, который пробует флипать каждый байт, в надежде нащупать поле clearance:

Position  54    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 55 (char 54)
Position  55    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 56 (char 55)
Position  56  → rule clearance >= 5 not satisfied, clearance must be an integer ← !
Position  57    rule clearance >= 5 not satisfied, clearance must be an integer
Position  58    rule clearance >= 5 not satisfied, clearance must be an integer
Position  59    rule clearance >= 5 not satisfied, clearance must be an integer
Position  60    rule clearance >= 5 not satisfied, clearance must be an integer
Position  61    rule clearance >= 5 not satisfied, clearance must be an integer
Position  62    rule clearance >= 5 not satisfied, clearance must be an integer
Position  63    rule clearance >= 5 not satisfied, clearance must be an integer
Position  64    rule clearance >= 5 not satisfied, clearance must be an integer
Position  65    JSON parsing error: Expecting ':' delimiter: line 1 column 73 (char 72)
Position  66    JSON parsing error: Expecting ':' delimiter: line 1 column 67 (char 66)
Position  67    JSON parsing error: Expecting value: line 1 column 68 (char 67)
Position  68  → rule clearance >= 5 not satisfied, your clearance = 3 ← !!!
Position  69    JSON parsing error: Expecting ',' delimiter: line 1 column 70 (char 69)

На позициях [56…64] ошибка говорит, что уровень допуска вообще отсутствует — похоже, в этом месте в JSON находится само слово "clearance", которое мы ломаем флиппингом.

На позиции [68] уровень допуска вместо 2 становится 3 — ожидаемо, вот где лежит значение этого параметра, наш скрипт флипает младший бит, b'2' ^ b'3' == 0b00000001.

Ксорим байт куки в позиции [68] на b'2' ^ b'5':

<....>
cookie = bytearray(b''.fromhex("28123600894ec0052e3abfa0039b98ec1efec27e389dcf65765bc4a5792bc8f7a1ba2a6b431f0c7d3ae93c4924a7e804f4f50b19b96c68d141c53dd904c381f40e05e93fa4180dc9a5dcc18381df5cd901c586d1075ae111db0bdff1f7e12477f6ea7ac9e6fe59152bd0d2b200680ccc88"))
xor = lambda A, B: bytes([a^b for a, b in zip(A, B)])
cookie[68:68+1] = xor(cookie[68:68+1], xor(b'2', b'5'))
<....>

Запускаем снова:

Position   2  → rule untrusted = false not satisfied, your untrusted = true

Position  69    JSON parsing error: Expecting ',' delimiter: line 1 column 70 (char 69)
Position  70    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 71 (char 70)
Position  71    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 72 (char 71)
Position  72    rule untrusted = false not satisfied, your untrusted is not set ← !
Position  73    rule untrusted = false not satisfied, your untrusted is not set
Position  74    rule untrusted = false not satisfied, your untrusted is not set
Position  75    rule untrusted = false not satisfied, your untrusted is not set
Position  76    rule untrusted = false not satisfied, your untrusted is not set
Position  77    rule untrusted = false not satisfied, your untrusted is not set
Position  78    rule untrusted = false not satisfied, your untrusted is not set
Position  79    rule untrusted = false not satisfied, your untrusted is not set
Position  80    rule untrusted = false not satisfied, your untrusted is not set
Position  81  → JSON parsing error: Expecting ':' delimiter: line 1 column 92 (char 91)
Position  82    JSON parsing error: Expecting ':' delimiter: line 1 column 83 (char 82)
Position  83  → JSON parsing error: Expecting value: line 1 column 84 (char 83)
Position  84    JSON parsing error: Expecting value: line 1 column 85 (char 84)
Position  85    JSON parsing error: Expecting value: line 1 column 85 (char 84)
Position  86    JSON parsing error: Expecting value: line 1 column 85 (char 84)
Position  87    JSON parsing error: Expecting value: line 1 column 85 (char 84)
Position  88  → JSON parsing error: Expecting ',' delimiter: line 1 column 89 (char 88)
Position  89    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 90 (char 89)
Position  90    JSON parsing error: Expecting property name enclosed in double quotes: line 1 column 91 (char 90)

По этим ошибкам мы можем узнать, что:

В данных содержится "untrusted":true, а нам нужно сделать false.

Судя по тому, что приложение:

  • ругается на запятую на позиции [69]

  • ожидает появления кавычки на [70…71]

  • не находит название поля «untrusted» на [72…80] 

  • ожидает двоеточие на [82], а на [81], видимо, ломается вторая кавычка

  • ожидает значения поля на [83…87]

  • снова ожидает запятую на [88] 

данные имеют вид , "untrusted": true, " с позиции [69].

Чтобы нафлипать себе false, превратим в него true вместе с пробелом (позиции [83…87]):

cookie[83:87+1] = xor(cookie[83:87+1], xor(b' true', b'false'))

Запускаем:

Position   2    rule allow-flag = 'yes' not satisfied, your allow-flag is not set

Судя по сообщению, поля allow-flag не существует — нам не у чего флипать значение.

Расшифровываем кукис целиком

Воспользуемся еще одним трюком: посмотрим, что будет, если флипать не один младший бит (^ 0b00000001), а сразу все биты байта (^ 0b11111111)

flipped[i] ^= 0b11111111  # флипаем у i-го байта все биты
root@vosus:/mnt/f# ./flipper.py
Position   0    Decryption error: 'utf-8' codec can't decode byte 0x84 in position 0: invalid start byte
Position   1    Decryption error: 'utf-8' codec can't decode byte 0xdd in position 1: invalid continuation byte
Position   2    Decryption error: 'utf-8' codec can't decode byte 0x8a in position 2: invalid start byte
Position   3    Decryption error: 'utf-8' codec can't decode byte 0x8c in position 3: invalid start byte
Position   4    Decryption error: 'utf-8' codec can't decode byte 0x9a in position 4: invalid start byte
Position   5    Decryption error: 'utf-8' codec can't decode byte 0x8d in position 5: invalid start byte
Position   6    Decryption error: 'utf-8' codec can't decode byte 0xdd in position 6: invalid continuation byte
Position   7    Decryption error: 'utf-8' codec can't decode byte 0xc5 in position 7: invalid continuation byte
Position   8    Decryption error: 'utf-8' codec can't decode byte 0xdf in position 8: invalid continuation byte
Position   9    Decryption error: 'utf-8' codec can't decode byte 0xdd in position 9: invalid continuation byte
Position  10    Decryption error: 'utf-8' codec can't decode byte 0x8b in position 10: invalid start byte
Position  11    Decryption error: 'utf-8' codec can't decode byte 0x9a in position 11: invalid start byte
Position  12    Decryption error: 'utf-8' codec can't decode byte 0x8c in position 12: invalid start byte

Теперь одинаковая ошибка возникает при изменении любого байта и еще до парсинга JSON — на этапе декодирования кодировки UTF-8, которая обычно используется в вебе. В UTF-8 буквы латинского алфавита, цифры, знаки препинания совпадают с кодировкой ASCII и кодируются одним байтом от 00 до 7f, а вот вторая половина байтов (80ff) занята под символы из нескольких байтов (буквы других алфавитов, значки, эмодзи и так далее.)

JSON целиком состоит из обычных латинских символов, и у них старший бит всегда 0: 0x00 == 0b00000000, 0x7f == 0b01111111. Когда мы флипаем у такого символа все биты, мы всегда получим байт из второй половины: 0x80 == 0b10000000, 0xff == 0b11111111.

Байты из второй половины используются в UTF-8 для символов из нескольких байтов и никогда не стоят по одиночке, поэтому декодер ругается, встретив такой одиночный байт. И даже говорит, какой именно байт ему попался!

Возьмем те байты, что нам сообщает декодер, и флипнем у них все биты обратно:

In [3]: chr(0x84 ^ 0b11111111)
Out[3]: '{'
In [4]: chr(0xdd ^ 0b11111111)
Out[4]: '"'
In [5]: chr(0x8a ^ 0b11111111)
Out[5]: 'u'
In [6]: chr(0x8c ^ 0b11111111)
Out[6]: 's'
In [7]: chr(0x9a ^ 0b11111111)
Out[7]: 'e'
In [8]: chr(0x8d ^ 0b11111111)
Out[8]: 'r'

Декодер UTF-8 слил нам байты из незашифрованного кукиса, потому что не смог их распарсить. Модифицируем скрипт, чтобы он брал сообщения от декодера и выводил нам все байты:

#!/usr/bin/python3
import requests
sess = requests.Session()

cookie = bytearray(b''.fromhex("28123600894ec0052e3abfa0039b98ec1efec27e389dcf65765bc4a5792bc8f7a1ba2a6b431f0c7d3ae93c4924a7e804f4f50b19b96c68d141c53dd904c381f40e05e93fa4180dc9a5dcc18381df5cd901c586d1075ae111db0bdff1f7e12477f6ea7ac9e6fe59152bd0d2b200680ccc88"))
xor = lambda A, B: bytes([a^b for a, b in zip(A, B)])
cookie[68:68+1] = xor(cookie[68:68+1], xor(b'2', b'5'))
cookie[83:87+1] = xor(cookie[83:87+1], xor(b' true', b'false'))

for i in range(len(cookie)):
    flipped = cookie[:]
    flipped[i] ^= 0b11111111  # флипаем у i-го байта все биты
    res = sess.get("https://t-capybit-kdot8z7j.spbctf.org/funds", cookies={"session": flipped.hex()})
    leaked = res.text[res.text.find('0x'):][:4]  # берём байт 0x** из сообщения декодера utf-8
    leaked = int(leaked, 16) ^ 0b11111111  # флипаем биты обратно
    print(chr(leaked), end='', flush=True)
root@vosus:/mnt/f# ./flipper.py
{"user": "testuser123", "attributes": {"role": "root", "clearance": 5, "untrusted":false, "department": "sales"}}

Теперь мы знаем целиком содержимое куки, на какой позиции какой текст находится в данных, и можем легко нафлипать себе последний оставшийся элемент привилегий "allow-flag":"yes" — например, на месте ненужного "department": "sales"

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

Бонус: расшифровываем за два запроса

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

Мы знаем, что логин, который мы регистрируем, попадает в кукис, который шифруется ксором на секретный поток бит:

↓ мы знаем, что где-то в данных наш юзернейм
userinfo *****************testuser123***********************
^ keystream ???????????????????????????????????????????????????
=> cookie d8e2e8556f7a477c236e4b06bb219962b2e6ccd640f4f4f5d0f
↑ мы получаем шифрованную куку
(для экономии места один символ userinfo написан над одним хексом cookie, в реальности на каждый символ userinfo в куке естественно приходится два хекса)

Благодаря свойству XOR, что раз A ^ B = C, то и A ^ C = B, мы можем переставить строчки:

↓ у нас есть кука
cookie d8e2e8556f7a477c236e4b06bb219962b2e6ccd640f4f4f5d0f
^ userinfo *****************testuser123*********************** ← известное
=> keystream ?????????????????4093c70c812????????????
узнаём ↑ соответствующую часть секретного потока

Узнать небольшую часть из середины секретного потока не сильно полезно, но давайте зарегистрируемся с очень длинным логином:

cookie 7a477c2bbfe508dab8ef9c6a8f12b3bbffc5a6b13bf442f82e83cdf8eda37a99a
^ userinfo *******qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq**
=> keystream ???????4093c70c81206a5c8e8f32563e883e7f4aae3e5ff45998417fe73bb4??
↑ узнаём большой хвост секретного потока

Если теперь на эту часть секретного потока поксорить куку от короткого логина, мы расшифруем и то, что хранится в ее данных после логина:

cookie 7a477c236e4b06bb219962b2e6ccd640f4f4f5d0f...
^ keystream ??????4093c70c81206a5c8e8f32563e883e7f4aae3e5ff45998417fe73bb4??
=> userinfo ??????testuser123","role":"user","cleara...

Это работает и в обратную сторону — для зашифровки измененных данных:

userinfo ???????testuser123","role":"root","cleara...
^ keystream ???????4093c70c81206a5c8e8f32563e883e7f4aae3e5ff45998417fe73bb4??
=> cookie 7a477c236e4b06bb219962b2e6cc8fe3f4f4f5d0f...

Скрипт, который реализует такую атаку с получением секретного шифропотока:

#!/usr/bin/python3
import requests

xor = lambda A, B: bytes([a^b for a, b in zip(A, B)])

URL = "https://t-capybit-kdot8z7j.spbctf.org"

print("[.] Registering short username")
res = requests.post(f"{URL}/register", data={"username": "qwe"}, allow_redirects=False)
short_cook = res.cookies['session']
print(f"[*] Got short cookie: {short_cook}")

print("[.] Registering long username")
res = requests.post(f"{URL}/register", data={"username": "q"*100}, allow_redirects=False)
long_cook = res.cookies['session']
print(f"[*] Got long cookie: {long_cook}")

cipherstream = xor(b''.fromhex(long_cook), b"q"*300)
plaintext = xor(b''.fromhex(short_cook), cipherstream)
print(f"[*] Decrypted cookie: {plaintext}")

patched = plaintext.replace(b'"role": "user"', b'"role": "root"')
new_cook = xor(patched, cipherstream).hex()
print(f"[*] Re-encrypted cookie: {new_cook}")

res = requests.get(f"{URL}/funds", cookies={"session": new_cook})
print(f"[*] 'role' bypass result: {res.text}")
root@vosus:/mnt/f# ./oneshot.py
[.] Registering short username
[*] Got short cookie: 28123600894ec0052e3abab215cdc1bf59ed873879d68130205fc3f33162d1a0a7b0352c5b05577d3df5355e24b1e804e5f6010ce9212690478b6298448ecfb51e49a76de347598eb4908fd180de5dd949c79e951658f506834698fbe6b37f25a0f47ecbf7af414874
[.] Registering long username
[*] Got long cookie: 28123600894ec0052e3abab4019e9cee0afd823d7ace9234254bc1a07a33dbf3a4ae2838084e062e39f7215d77ecb957f7eb151cea31398253d829c907d39ee61a56a26ee7455c9aa1c3c48085dd59cd1496cd800259e505865a8ce4e3e03474f3f66ed6e3ad124478d2c2af146a0c9dd5e1b5e37e181895e437b54bfb61ad601a0e055cab5c60e2bd98bdb7833759b9df310dea64ce9a874125cfa8a510448e8d182c2682f4bc2057f951c74691f3ad9eed62692d96af0c381b8760517e609a736f14983f3395e05c51
[*] Decrypted cookie: b'qqqqqqqqqqqwe", "attributes": {"role": "user", "clearance": 2, "untrusted": true, "department": "sales"}}'
[*] Re-encrypted cookie: 28123600894ec0052e3abab215cdc1bf59ed873879d68130205fc3f33162d1a0a7b0352c5b05577d3ae93f5824b1e804e5f6010ce9212690478b6298448ecfb51e49a76de347598eb4908fd180de5dd949c79e951658f506834698fbe6b37f25a0f47ecbf7af414874
[*] 'role' bypass result: rule clearance >= 5 not satisfied, your clearance = 2

Можно заметить, что этот метод восстанавливает не весь шифропоток — все, что в данных находится левее логина, мы не можем восстановить. Но это не мешает нам полноценно работать с частично расшифрованной кукой и перешифровывать ее с новым содержимым.

Что еще посмотреть

  1. Подборка материалов для начинающих от сообщества SPbCTF.

  2. Сезон заданий SPbCTF по криптографии.

  3. Разборы демозаданий 2023 года: Crypto (baby), Web (easy), Android (medium) — и 2024 года: AI (baby), Android (easy), Web (medium).

  4. Разбор задания «Выигрываем миллион у робошулера».

  5. Разбор задания о плоской Земле. 

А теперь начинаем соревнование

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

P.S. Спасибо Владимиру Лебедеву @D3fl4t3 за подготовку задания и Владу Роскову @v0s за разбор 🫶

Теги:
Хабы:
Всего голосов 17: ↑17 и ↓0+21
Комментарии0

Публикации

Информация

Сайт
l.tbank.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия