Към съдържанието
wannacode

JWT и капаните му

Накратко: JWT е подписан токен от header, payload и подпис; payload-ът не е криптиран и се чете от всеки, а сървърът трябва сам да проверява алгоритъма, подписа и exp.

Проблем

Токенът, който стана admin

Мобилното приложение на магазина не ползва страници, а API. Екипът избира JWT, „за да не пази сървърът сесии“, и копира проверката на токена от стара статия в блог. Месеци по-късно изследовател на сигурността пише на магазина: с токен, който е написал на ръка, е влязъл в API-то за възстановяване на пари като admin.

Ключът на магазина не е изтекъл. Паролата на администратора също. Изследователят не е разбил нищо: проверката сама е приела токена му.

Идеята

Подписан пропуск

Сесията от предишния урок е номерче от гардероба: сървърът пази таблица кой е зад всяко id. JWT (JSON Web Token) е друг подход: самият токен казва кой си и до кога важи, а сървърът го подписва, за да не може да се промени.

Токенът има три части, разделени с точки:

  • header: как е подписан, например {"alg":"HS256","typ":"JWT"};
  • payload: какво казва, например {"sub":"maria","exp":1793000900}. sub е потребителят, exp е моментът на изтичане в секунди от 1.1.1970;
  • подпис: HMAC-SHA256 върху „header.payload“ с таен ключ на сървъра.

Header-ът и payload-ът са JSON, записан в base64url: base64 с - и _ вместо + и / и без = накрая, за да става за адрес и заглавка. Base64url е запис, не криптиране. Токенът е подписан, не таен: всеки го чете, но без ключа никой не може да го промени, без подписът да спре да съвпада.

Виж го

Трите части и подправеният токен

static string Verify(string token, long now)
{
var parts = token.Split('.');
if (parts.Length != 3) return "✗ формат";
// алгоритъмът е закован: не го взимаме от токена
var alg = (string?)Json(parts[0])?["alg"];
if (alg != "HS256") return "✗ алгоритъм";
var data = parts[0] + "." + parts[1];
if (!Same(Sign(data), parts[2])) return "✗ подпис";
var payload = Json(parts[1]);
var exp = (long?)payload?["exp"];
if (exp is null || now >= exp) return "✗ изтекъл";
return "✓ " + (string?)payload?["sub"];
}
Стъпка по стъпка, с думи
  1. Header-ът казва как е подписан токенът: HS256. JSON-ът става текст в base64url.
  2. Payload-ът казва кой и до кога: sub maria, exp след 15 минути. Също base64url.
  3. Подписът е HMAC-SHA256 върху „header.payload“ с ключа на сървъра. Без ключа никой не може да го направи.
  4. Токенът е трите части с точки. Мобилното приложение го праща с всяка заявка.
  5. Който има токена, чете payload-а без ключ: base64url не е криптиране. Подписан е, не е таен.
  6. Сега подправен токен: header-ът казва alg none, payload-ът казва sub admin, а подпис няма.
  7. Наивната проверка пита токена кой алгоритъм да ползва. „none“ прескача подписа и admin влиза.
  8. Поправката заковава алгоритъма: само HS256, решено от сървъра. alg none е отказан преди всичко друго.
  9. Истински, но стар токен: подписът е верен, но exp е минал. Наивната проверка го пусна, тази не.
  10. Три проверки в този ред: алгоритъмът, избран от сървъра, подписът, срокът. Payload-ът никога не е тайна.

Подписът и base64url са в стандартната библиотека на всеки език. Ключът тук е демо: истинският е 32 случайни байта, пазени извън кода (урокът „Тайни“).

// демо ключ; истинският е 32 случайни байта от хранилище за тайни
static readonly byte[] Key = Encoding.UTF8.GetBytes("demo-key-only-for-this-lesson");
static string Encode(string json) =>
Base64Url.EncodeToString(Encoding.UTF8.GetBytes(json));
static string Sign(string data)
{
var bytes = Encoding.UTF8.GetBytes(data);
return Base64Url.EncodeToString(HMACSHA256.HashData(Key, bytes));
}
static string Issue(string user, long exp)
{
var header = """{"alg":"HS256","typ":"JWT"}""";
var payload = JsonSerializer.Serialize(
new { sub = user, exp });
var data = Encode(header) + "." + Encode(payload);
return data + "." + Sign(data);
}
JsonSerializer.Serialize(new { sub = user, exp }) дава {"sub":"maria","exp":…} без интервали. Base64Url (от .NET 9) прави base64url направо, а HMACSHA256.HashData смята подписа с едно извикване.

Токен за Мария, който важи 15 минути:

long now = 1_793_000_000;
var token = Issue("maria", now + 900);
var parts = token.Split('.');
Console.WriteLine("header: " + parts[0]);
Console.WriteLine("payload: " + parts[1]);
Console.WriteLine("подпис: " + parts[2]);
var payload = Encoding.UTF8.GetString(Base64Url.DecodeFromChars(parts[1]));
Console.WriteLine("payload без ключ: " + payload);
Изход
header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload: eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0
подпис: mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw
payload без ключ: {"sub":"maria","exp":1793000900}

Ключът и „сега“ са фиксирани, затова токенът е един и същ при всяко пускане и на четирите езика. Последният ред е payload-ът, прочетен без ключа.

Пробвай

Предвиди изхода

Някой е хванал токена на Мария. Ключ няма. Какво ще отпечата?

var token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw";
var payload = token.Split('.')[1];
var bytes = Base64Url.DecodeFromChars(payload);
Console.WriteLine(Encoding.UTF8.GetString(bytes));
Отговор

Идеята

Капан 1: алгоритъмът идва от токена

Ето проверката от статията. За да чете header и payload, тя ползва две помощни функции:

static JsonNode? Json(string part) =>
JsonNode.Parse(Base64Url.DecodeFromChars(part));
// НЕ ТАКА: вярва на алгоритъма от самия токен
static string NaiveVerify(string token)
{
var parts = token.Split('.');
var alg = (string?)Json(parts[0])?["alg"];
if (alg == "HS256")
{
var data = parts[0] + "." + parts[1];
if (Sign(data) != parts[2])
return "✗ подпис";
}
// "none": токенът сам казва, че няма подпис
return "✓ " + (string?)Json(parts[1])?["sub"];
}

Тя чете alg от header-а и проверява подписа само ако там пише HS256. Стандартът наистина има алгоритъм none: „токен без подпис“. Ето четири токена: истинският, същият с подменен payload, ръчно написан токен с alg: none, sub: admin и празен подпис, и истински, но изтекъл токен:

long now = 1_793_000_000;
var good = Issue("maria", now + 900);
var p = good.Split('.');
var admin = JsonSerializer.Serialize(new { sub = "admin", exp = now + 900 });
// подменен payload, старият подпис
var changed = p[0] + "." + Encode(admin) + "." + p[2];
// alg: none и никакъв подпис
var none = Encode("""{"alg":"none","typ":"JWT"}""") + "." + Encode(admin) + ".";
var old = Issue("maria", now - 60);
Console.WriteLine("истински: " + NaiveVerify(good));
Console.WriteLine("подменен payload: " + NaiveVerify(changed));
Console.WriteLine("alg: none: " + NaiveVerify(none));
Console.WriteLine("изтекъл: " + NaiveVerify(old));
Изход
истински: ✓ maria
подменен payload: ✗ подпис
alg: none: ✓ admin
изтекъл: ✓ maria

Подменен payload се хваща, защото подписът спира да съвпада. Но alg: none пуска admin без никакъв подпис, а изтеклият токен минава, защото exp не се гледа. Header-ът е вход от потребителя, като всичко в токена, докато подписът не е проверен (урокът „Недоверен вход“). Проверка, която пита токена как да го провери, вярва на атакуващия.

Виж го

Поправката: три проверки в ред

static string Verify(string token, long now)
{
var parts = token.Split('.');
if (parts.Length != 3) return "✗ формат";
// алгоритъмът е закован: не го взимаме от токена
var alg = (string?)Json(parts[0])?["alg"];
if (alg != "HS256") return "✗ алгоритъм";
var data = parts[0] + "." + parts[1];
if (!Same(Sign(data), parts[2])) return "✗ подпис";
var payload = Json(parts[1]);
var exp = (long?)payload?["exp"];
if (exp is null || now >= exp) return "✗ изтекъл";
return "✓ " + (string?)payload?["sub"];
}
static bool Same(string a, string b) =>
// сравнява всички байтове, без да спира при първата разлика
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
  1. Алгоритъмът е закован. Сървърът знае, че подписва с HS256, и не приема нищо друго. none спира тук.
  2. Подписът се сравнява за постоянно време, както CSRF токенът в предишния урок.
  3. exp е задължителен. Токен без exp или с минал exp е отказ.

Същите четири токена:

long now = 1_793_000_000;
var good = Issue("maria", now + 900);
var p = good.Split('.');
var admin = JsonSerializer.Serialize(new { sub = "admin", exp = now + 900 });
// подменен payload, старият подпис
var changed = p[0] + "." + Encode(admin) + "." + p[2];
// alg: none и никакъв подпис
var none = Encode("""{"alg":"none","typ":"JWT"}""") + "." + Encode(admin) + ".";
var old = Issue("maria", now - 60);
Console.WriteLine("истински: " + Verify(good, now));
Console.WriteLine("подменен payload: " + Verify(changed, now));
Console.WriteLine("alg: none: " + Verify(none, now));
Console.WriteLine("изтекъл: " + Verify(old, now));
Изход
истински: ✓ maria
подменен payload: ✗ подпис
alg: none: ✗ алгоритъм
изтекъл: ✗ изтекъл

Пробвай

Попълни

Кой алгоритъм приема проверката?

var alg = (string?)Json(parts[0])?["alg"];
if (alg != "") return "✗ алгоритъм";

Пробвай

Намери грешката

Тази проверка заковава HS256 и сравнява подписа правилно. Но една стара версия на приложението издаваше токени без exp, а те и днес работят. На кой ред е грешката?

Избери реда с грешката.

Идеята

Капан 2: къде стои токенът и как се отменя

localStorage. Уеб приложенията често пазят JWT в localStorage, защото е удобно. Но всеки скрипт на страницата чете localStorage. Една XSS дупка и чуждият скрипт праща токена на атакуващия, който го ползва до изтичането му. Подписът не помага: токенът не е подправен, а откраднат цял. В браузъра по-сигурното място е бисквитка с HttpOnly, но тогава тя тръгва сама и трябва защитата от CSRF от предишния урок.

Отмяна. Сървърът проверява само подписа и exp. Ако телефонът на Мария е откраднат, токенът ѝ работи до exp, дори да смени паролата си. Затова токените за достъп са кратки (5 до 15 минути), а дългият вход е с refresh токен, който сървърът пази и може да изтрие. Друг начин е списък с отменени токени на сървъра, но тогава сървърът пак пази състояние, както при сесията.

Кога сесията е по-проста. Ако имаш един уеб сайт с един сървър, бисквитката със сесия от предишния урок е по-простото решение: отменя се, като изтриеш реда, няма подпис за проверяване и няма алгоритъм, който да се обърка. JWT има смисъл, когато токена го проверяват много услуги без обща таблица със сесии, или между различни фирми (вход с Google е OpenID Connect, а той е JWT).

По-точно, за любопитните

Библиотеките. В истински проект JWT не се пише на ръка: Java има jjwt, java-jwt (Auth0) и Nimbus JOSE + JWT; C# има Microsoft.IdentityModel.JsonWebTokens, а ASP.NET Core проверява токените с AddJwtBearer; Python има PyJWT; Node има jsonwebtoken и jose. И с библиотека алгоритъмът се заковава изрично: jwt.decode(token, key, algorithms=["HS256"]) в PyJWT, jwt.verify(token, key, { algorithms: ["HS256"] }) в jsonwebtoken, ValidAlgorithms в TokenValidationParameters, verifyWith(key) в jjwt. Старите версии на някои библиотеки приемаха none, когато не им кажеш алгоритъма.

RS256 и ES256. С тях подписва частен ключ, а проверява публичен: така много услуги проверяват, без да могат да подписват. Тогава има и друг капан, объркване на алгоритъма: сървър, който очаква RS256, но пита токена, може да приеме HS256 с публичния ключ като HMAC ключ. Поправката е същата: закован алгоритъм.

Други полета. iat (кога е издаден), nbf (не по-рано от), aud (за коя услуга е), iss (кой го е издал), jti (уникално id, за отмяна). Часовниците на сървърите се разминават, затова библиотеките позволяват няколко секунди толеранс при exp.

Криптиран JWT има (JWE), но рядко трябва: по-просто е да не слагаш тайни в токена.

Упражнение

Изход от всички устройства

Реши задачата в редактора си.

Мария губи телефона си и натиска „Изход от всички устройства“. Добави в payload-а jti, случайно уникално id на токена. Напиши Logout, който записва jti на токена в множество от отменени, и проверка, която след verify отказва отменения токен с „✗ отменен“. Новият вход дава нов токен, който работи.

Изход
преди изхода: ✓ maria
след изхода: ✗ отменен
нов вход: ✓ maria

Още задачи

  • Кратък живот. Направи токена за достъп 5 минути и добави refresh токен, който се пази на сървъра в таблица. Какво трябва да изтриеш при „Изход от всички устройства“ сега?
  • Чистене. Отмененото jti трябва да се пази само докато токенът и без това не изтече. Пази и exp и махай старите записи.

Тест

Провери се

1. Кой може да прочете payload-а на JWT?
2. Защо проверката заковава алгоритъма (само HS256), вместо да го прочете от header-а?
3. Уеб приложение пази JWT в localStorage. Какво става, ако страницата има XSS дупка?
4. Телефонът на Мария е откраднат, а токенът на приложението важи 30 дни. Какво може магазинът?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·