
Проводим третий 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"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 ******** ******** ******** *****%** ******** ... # ***%*Именно это произошло, когда мы изменили в куке 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"
