
Проводим третий 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 и длинной строчкой внутри.

Экспериментируем с кукой
По кукису 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. И раз в хексах мусор, данные внутри кукиса зашифрованы либо сжаты — это два преобразования, которые превращают данные в трэш.
Попробуем регистрироваться под разными логинами, чтобы собрать побольше информации:
| Кукис |
testuser123 |
|
testuser234 |
|
testuser234567890 |
|

Какие закономерности можно увидеть:
Когда мы поменяли в логине три символа (123 → 234), в куке поменялись три байта (шесть хексов:
c27e38→c17f3f), а все остальные байты остались прежними.Каждый байт поменялся несильно:
c2 → c1,7e → 7f,38 → 3f.Когда мы увеличили логин на +6 символов, кукис вырос на 6 байтов (12 хексов), при этом начало всей куки осталось тем же, но конец полностью изменился.
Пришло время попробовать самим что-нибудь поменять в куке и посмотреть, как на это отреагирует приложение. Возьмем последний длинный кукис и слегка поменяем хексы в месте, которое менялось при изменении логина: c17f3f8ad5726c0380 → c0703080d070600080
Удобно менять у каждого байта младшую половинку, чтобы изменения были небольшими: так выше шанс вызвать слегка другое поведение, а не сломать все.
Вот наша новая кука с нулями в несколько байтов, скормим ее приложению: 28123600894ec0052e3abfa0039b98ec1efec0703080d070600080f3276288e3a1ab2b201b4a033a3ba46a0c7dbfba49eaff4657bb623d8047db7a9456808cfb0e46a17ef85748c9ea9287ddd48e5dd21195c982074df056cd0b89e7e7f46925a0e37ad7f3ae17586ccdc7fc5f3b0cc294afb1e428170c
Подставляем подменную куку на вкладке Application → Cookies и смотрим, что произошло:

Наш логин поменялся с 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» мог получиться при 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"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 ******** ******** ******** *****%** ******** ... # ***%*
Именно это произошло, когда мы изменили в куке c17f3f8ad5726c0380 → c0703080d070600080 и получили смену логина testuser234567890 → testuser3<;?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' <....>
Богатый набор реакций! Что мы узнали:
В оригинальных данных лежит JSON.
На позиции [1] двойная кавычка
", на [6]:, на [52],— похоже, зашифрован JSON-объект вида{"field":"value","field":1234}.На позициях [48…51] лежит наша роль
user! Мы прошлись по ней флипами младшего бита:tserurerusdruses, а приложение ругается, что у нас должна быть роль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' # получили те же модифицированные данные, что до этого руками
Меняем в куке f3e9011f → f4f50b19 и смотрим на реакцию приложения:

Отлично! Мы прошли проверку роли, успешно бит-флипнув себе user → root. Теперь приложение проверяет следующие условия. Зарядим новую куку с рутовой ролью в скрипт, который пробует флипать каждый байт, в надежде нащупать поле 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, а вот вторая половина байтов (80—ff) занята под символы из нескольких байтов (буквы других алфавитов, значки, эмодзи и так далее.)
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
Можно заметить, что этот метод восстанавливает не весь шифропоток — все, что в данных находится левее логина, мы не можем восстановить. Но это не мешает нам полноценно работать с частично расшифрованной кукой и перешифровывать ее с новым содержимым.
Что еще посмотреть
Разборы демозаданий 2023 года: Crypto (baby), Web (easy), Android (medium) — и 2024 года: AI (baby), Android (easy), Web (medium).
А теперь начинаем соревнование
Мы разобрали одно из демозаданий. Если остались вопросы, приглашаем на страницу соревнования — там мы даем больше информации и ссылок на дополнительные материалы. А если вы уже готовы порешать задачки для победы — скорее регистрируйтесь!
P.S. Спасибо Владимиру Лебедеву @D3fl4t3 за подготовку задания и Владу Роскову @v0s за разбор 🫶
