Sprawa hasła, które zadzwoniło

Krótka historia detektywistyczna o bazie danych, która nie umiała przeczytać własnej konfiguracji, haśle, które wyglądało idealnie w każdym narzędziu, które go dotknęło, i jednym bajcie, który ukrywał się na widoku na długo, zanim ktokolwiek go wpisał.

 ┌──────────────────────────────────────────────────────────────┐
 │                      TABLICA ŚLEDCZA                         │
 ├──────────────────────────────────────────────────────────────┤
 │                                                              │
 │     [ baza analityczna w pętli restartów: "invalid token" ]  │
 │                          │                                   │
 │        ┌─────────────────┼─────────────────┐                 │
 │        ▼                 ▼                 ▼                 │
 │  ┌───────────┐    ┌───────────┐    ┌───────────┐             │
 │  │PODEJRZANY │    │PODEJRZANY │    │PODEJRZANY │             │
 │  │ nasz YAML │    │ konflikt  │    │   chart   │             │
 │  │           │    │ portu 9009│    │   Helma   │             │
 │  └─────┬─────┘    └─────┬─────┘    └─────┬─────┘             │
 │        │ niewinny       │ niewinny       │ niewinny          │
 │        ▼                ▼                ▼                   │
 │           ┌──────────────────────────────┐                   │
 │           │      FAKTYCZNY SPRAWCA       │                   │
 │           │  jeden niedrukowalny bajt w  │                   │
 │           │  haśle — niewidoczny dla     │                   │
 │           │  każdego narzędzia           │                   │
 │           └──────────────────────────────┘                   │
 │                                                              │
 │   dowody: to samo hasło, wydrukowane, wyglądało poprawnie    │
 │          ...a ponowne zakodowanie dało inny ciąg             │
 └──────────────────────────────────────────────────────────────┘

Zgłoszenie

Firma uruchamia na Kubernetesie kolumnową bazę analityczną, wdrażaną przez GitOps z repozytorium opisującego środowisko QA. Przechowuje metryki szeregów czasowych. Usługa dashboardów czyta z niej przez dedykowane konto tylko do odczytu, a hasło tego konta pochodzi z AWS Secrets Manager, dostarczane do klastra przez External Secrets Operator i przekazywane bazie jako zmienna środowiskowa.

Rutyna. Mniej więcej czterdzieści innych usług w tym repozytorium działa tak samo.

Zgłoszenie mówiło, że pod bazy nie chce wystartować. Był w pętli restartów i to z najmniej obiecującym rodzajem błędu — porażką parsowania konfiguracji:

Failed to preprocess config '/etc/clickhouse-server/users.yaml':
SAXParseException: Invalid token in line 1 column 13

Ale dołączony był szczegół, który czynił całość dużo ciekawszą niż literówka. Kolega już coś spróbował: zastąpić odwołanie do hasła zahardkodowanym dosłownym ciągiem — i baza uruchamia się idealnie. Przywróć odwołanie do sekretu i umiera.

Plik konfiguracji był w obu przypadkach identyczny bajt w bajt poza tą jedną wartością. To więc nie był błąd w YAML. Coś w pobieraniu hasła zabijało bazę.

Rozdział 1 — Trzy zeznania i trup

Log był w większości szumem, i to z tego głośnego rodzaju:

Poco::Exception ... Not found: tmp_path
Poco::Exception ... Not found: user_files_path
Warning: Effective user of the process does not match the owner of the data
Warning: Listen [0.0.0.0]:9009 failed: Address already in use
Error:   Failed to preprocess config '.../users.yaml': SAXParseException ...

Trzy z nich to wabiki, i warto je nazwać, bo w takich incydentach kosztują prawdziwy czas.

Linie Not found: pochodzą z pomocnika, którego entrypoint kontenera uruchamia przed startem serwera. Pojawiają się też na zdrowych podach.

„Address already in use” to dobry wabik. Wygląda na prawdziwy, wart działania problem sieciowy — kolizję portów, zduplikowany Service, błędną konfigurację IPv6. Nie jest żadnym z nich. W podzie z jednym kontenerem jedyną rzeczą, która może trzymać port, jest poprzednia kopia tego samego procesu, wciąż zamykająca się po ostatniej awarii. To objaw pętli restartów. Zniknąłby w chwili naprawienia prawdziwego problemu.

Warta zachowania zasada: w ciasnej pętli restartów większość logu to wrak poprzedniej próby, a wrak często jest bardziej fotogeniczny niż przyczyna.

Trupem była ostatnia linia. I było w niej coś dziwnego.

Plik nazywa się users.yaml. Błąd to błąd parsera XML. SAXParseException to nie jest to, co mówi niezadowolony parser YAML. Coś w tym łańcuchu zamieniło nasz YAML w XML, a potem się na nim zakrztusiło.

Rozdział 2 — Podejrzany z trzema alibi

Naturalnym pierwszym podejrzanym była konfiguracja, którą napisaliśmy. Odwołanie do hasła używało nieco egzotycznej składni — format konfiguracji tej bazy pozwala, żeby klucz YAML zaczynający się od @ stał się atrybutem XML, i tak właśnie mówi się „weź tę wartość ze zmiennej środowiskowej”:

dashboard_reader:
  password:
    - '@from_env': DASHBOARD_DB_PASSWORD

To wygląda na coś, co jest subtelnie źle. Może lista tam, gdzie spodziewano się skalara, albo złe cudzysłowy przy @.

Tyle że ten sam plik definiował trzy inne konta w identycznym kształcie, wskazujące na trzy różne zmienne środowiskowe, i wszystkie trzy działały od tygodni. Cokolwiek było nie tak, to nie była składnia.

Struktura była więc niewinna, chart był niewinny — okazał się cienkim przekaźnikiem, który po prostu kopiuje twoje wartości do zasobu niestandardowego do wyrenderowania przez operator, bez żadnego szablonowania do obwiniania — i została jedyna rzecz różniąca działający przypadek od zepsutego: sama wartość.

Czas przeczytać źródła.

Rozdział 3 — Mechanizm w jednej linii

Open source ma w trakcie incydentu cudowną właściwość: możesz pójść i zobaczyć.

Procesor konfiguracji bazy implementuje podstawianie zmiennych środowiskowych tak:

env_document = dom_parser.parseString("<from_env>" + std::string{env_val} + "</from_env>");

Przeczytaj to powoli, bo wszystko z tego wynika.

Żeby wstrzyknąć zmienną środowiskową do konfiguracji, serwer buduje mały dokument XML, sklejając ciągi znaków, i parsuje go ponownie. Nie ma kroku escapowania. Jakiekolwiek bajty są w tej zmiennej, zostają wklejone wprost do źródła XML i przekazane parserowi XML.

A to znaczy, że ograniczenie nie dotyczy twojej konfiguracji. Ograniczenie dotyczy twojego sekretu. Hasło zawierające & albo < nie jest dla tej ścieżki kodu hasłem — to błędny XML, a serwer odmawia startu.

To też idealnie tłumaczy trop z zahardkodowaną wartością. Kiedy wartość jest wpisana dosłownie w YAML, obsługuje ją zupełnie inna ścieżka kodu: parser YAML buduje węzeł konfiguracji przez API dokumentu, gdzie escapowanie jest automatyczne, a nic nie jest parsowane ponownie. Tylko ścieżka podstawiania skleja ciągi.

Ta asymetria to naprawdę przydatne narzędzie diagnostyczne, które uogólnia się daleko poza tę bazę:

Zastąp pośredniość dosłowną wartością. Jeśli hardkod działa, a wyszukiwanie zawodzi, problemem są bajty wartości albo mechanizm wyszukiwania — a nie struktura konfiguracji.

Rozdział 4 — Gdzie się pomyliłem

Miałem już mechanizm i zrobiłem się zachłanny.

Błąd mówił linia 1, kolumna 13. Wstrzykiwany wrapper <from_env> ma dokładnie dziesięć znaków. Hasło zaczyna się więc w kolumnie 11 i z odrobiną arytmetyki mogłem powiedzieć inżynierowi, na który dokładnie znak hasła ma spojrzeć. Nawet to odtworzyłem — sterując z Pythona tym samym parserem XML, którego używa baza, i podając mu zmyślone hasła, aż błąd się zgadzał:

import xml.parsers.expat as x
p = x.ParserCreate()
try:    p.Parse("<from_env>" + pw + "</from_env>", True)
except x.ExpatError as e:
    print(x.ErrorString(e.code), p.ErrorLineNumber, p.ErrorColumnNumber)

Wyszedł pewny siebie, konkretny odcisk palca: drugi znak twojego hasła to & albo <, a trzeci to cyfra lub symbol. Znak drugi. Powiedziałem to wprost.

To było błędne, i to z najnudniejszego możliwego powodu.

Numery kolumn nie mają uniwersalnego punktu startowego. Parser pod spodem liczy kolumny od zera. Biblioteka C++, która go opakowuje, przekazuje tę liczbę dalej bez konwersji na konwencję od jedynki, której używa każdy edytor tekstu. Założyłem, że konwersja następuje. Ten jeden błąd o jeden przeniósł oskarżenie na inny znak, a każdy pewny siebie szczegół, który na tym zbudowałem, odziedziczył błąd.

Arytmetyka, kiedy faktycznie ją zmierzyć, zamiast zakładać:

indeks znaku = zgłoszona kolumna − (długość wstrzykniętego prefiksu) + 1

Co stawia winowajcę na znaku czwartym, a nie drugim.

Złe zakręty są tanie, kiedy je sprawdzasz. Ten był tani, bo inżynier poszedł i spojrzał na prawdziwą wartość, zamiast brać mój odcisk palca za dobrą monetę — a to, co przyniósł, było dużo lepsze od mojej odpowiedzi.

Rozdział 5 — Bajt, który zadzwonił

Pobrał zapisany sekret i go zdekodował. Oto jak to wygląda, na zmyślonych wartościach, które zachowują się dokładnie jak prawdziwe:

$ printf %s 'cXR6Bzh2YXVsdDNNblBr' | base64 -d
qtz8vault3MnPk

Czternaście znaków. Wygląda jak hasło. Wygląda poprawnie.

Potem zakodował ponownie to, co właśnie zobaczył, żeby sprawdzić:

$ printf %s 'qtz8vault3MnPk' | base64
cXR6OHZhdWx0M01uUGs=

Inny ciąg. Zdekoduj wartość, zakoduj ponownie, dostań coś innego. To niemożliwe — chyba że to, co zobaczyłeś, nie było tym, co tam było.

$ printf %s 'cXR6Bzh2YXVsdDNNblBr' | base64 -d | od -An -c
   q   t   z  \a   8   v   a   u   l   t   3   M   n   P   k

I jest. \a — bajt 0x07, ASCII BEL, dzwonek terminala. Znak czwarty.

Nigdy nie pojawił się na ekranie, bo BEL to nie znak, tylko instrukcja. Jego wydrukowanie niczego nie rysuje; prosi terminal o wydanie dźwięku. Hasło miało piętnaście bajtów, a wyświetlało czternaście. Każde narzędzie w łańcuchu było idealnie uczciwe i kompletnie bezużyteczne: magazyn sekretów go przechowywał, operator go dostarczył, powłoka go wydrukowała i żadne nie miało powodu o nim wspominać.

XML 1.0 zabrania znaków sterujących w treści — dozwolone są tylko tabulator, nowa linia i powrót karetki. Kiedy więc baza wkleiła to hasło do swojego małego fragmentu XML, powstał dokument, którego nie przyjmie żaden zgodny parser. Jeden niesłyszalny bajt i baza, która nie umiała przeczytać własnej konfiguracji.

A pozycją był znak czwarty. Dokładnie tam, gdzie kazała szukać poprawiona arytmetyka.

Rozdział 6 — Zwrot akcji: kodowanie nigdy nie było zepsute

Oto część, która zamienia jednorazowy przypadek w lekcję.

Oczywista historia brzmi „base64 się uszkodziło”. Nie uszkodziło. Ustaw oba ciągi w czteroznakowych grupach, w których base64 faktycznie pracuje:

   zapisany:  cXR6  Bzh2  YXVs  dDNN  blBr        -> 15 bajtów
   zamierzony:cXR6  OHZh  dWx0  M01u  UGs=        -> 14 bajtów
              ^^^^  ^^^^
              ta sama wszystko dalej się różni

Literówka w ciągu base64 zmienia jedną grupę w miejscu albo w ogóle nie daje się zdekodować. Tu jest inaczej: wyrównanie rozjeżdża się po pierwszej grupie, a liczby bajtów się nie zgadzają. To sygnatura poprawnego zakodowania złych danych wejściowych.

Innymi słowy zabłąkany bajt był już w tekście jawnym, zanim ktokolwiek zakodował go w base64. Kodowanie wiernie wykonało swoją pracę. Cokolwiek wygenerowało lub wkleiło to hasło, wyprodukowało zły bajt, a ponowne wpisanie sekretu naprawiło ten przypadek i nic w przyczynie.

I to jest ta niewygodna część. Nikt nie wie, jak BEL trafił do hasła. Kopiuj-wklej skądś, gdzie był znak sterujący, skrypt generujący, przejście przez schowek w narzędziu, które chciało dobrze. To nierozwiązane, czyli może się powtórzyć, po cichu, dokładnie tak samo.

Jeden szczegół strukturalny pogarszał sprawę. Konfiguracja External Secrets używała strategii dekodowania base64, czyli magazyn sekretów celowo przechowuje dane zakodowane, a operator dekoduje je przy wejściu. Prawdziwe bajty były więc niewidoczne w konsoli chmury (zakodowane), niewidoczne w Secret Kubernetesa (zakodowane ponownie) i niewidoczne w terminalu (niedrukowalne). Nie było warstwy, na której człowiek mógłby to zobaczyć, nie szukając celowo właściwym narzędziem.

Rozdział 7 — Poprawka i poprawka, która byłaby gorsza

Naprawa polegała na poprawnym ponownym zakodowaniu hasła i zapisaniu go jeszcze raz. Żadna zmiana w kodzie. Żadna zmiana w manifeście. Baza wstała przy następnej synchronizacji.

Była jednak kusząca alternatywa, która zasługuje na etykietę ostrzegawczą, bo to poprawka, po którą większość ludzi sięgnęłaby przy drugiej wersji tego błędu — tej, gdzie hasło zgodnie z zamiarem zawiera ampersand.

Jeśli problemem jest XML, escapuj. Zapisz &amp; zamiast &. Parser XML bazy odescapuje to z powrotem do &, konto dostanie zamierzone hasło, wszystko działa.

Wszystko poza usługą dashboardów, która czyta ten sam klucz sekretu i niczego nie odescapowuje. Wysłałaby dosłowne &amp;. Baza miałaby jedno hasło, dashboard wysyłałby inne, a porażka wypłynęłaby później jako błąd uwierzytelniania bez widocznego związku ze zmianą kodowania, która go spowodowała.

To jest pułapka: escapowanie w spoczynku zamienia głośną awarię przy starcie w ciche niedopasowanie. Sekret współdzielony przez rygorystycznego konsumenta i pobłażliwego nie może nieść escapowania żadnego z nich. Magazyn trzyma jedną kanoniczną wartość; każdy czytelnik robi własne cytowanie.

Opcje, które faktycznie działają:

  1. Ogranicz wartość do tego, co przyjmuje każdy konsument — bez &, bez <, bez bajtów sterujących. Naprawia przypadek, niczego nie wymusza.
  2. Sprawdzaj bajty w chwili zapisu, zanim w ogóle trafią do magazynu sekretów. Naprawia klasę problemów.
  3. Daj rygorystycznemu konsumentowi inną reprezentację. Ta baza przyjmuje zamiast hasła jego skrót SHA-256. Wyjście szesnastkowe nie może zawierać wrogiego bajtu, więc żadna wartość nigdy więcej nie zepsuje konfiguracji. Kosztuje to drugi klucz sekretu synchronizowany z tekstem jawnym, którego dashboard nadal potrzebuje — i dlatego to jest właściwa odpowiedź, a zarazem ta, której nie przyjęto.

Opcja 2 to dwulinijkowa kontrola i to za nią bym faktycznie optował:

printf %s "$B64" | base64 -d | LC_ALL=C grep -qP '[^\x20-\x7E]' \
  && echo 'REJECT: non-printable byte' || echo OK

Warto jednak zauważyć, gdzie nie może żyć: repozytoria w tej historii w ogóle nie mają CI — kontroler wdrożeń czyta gałąź main bezpośrednio. Nie ma potoku, na którym można by zawiesić kontrolę. To luka z rosnącą kolejką rzeczy, które by jej chciały.

Wskazówki dla czytelnika