← Notatki

Paweł · 15 września 2026

Kanał boczny, który stał się protokołem

To jest opis protokołu, którego nikt nie zaprojektował.

W lipcu 2026 OpenAI puściło dziesiątki tysięcy programów na jeden test bezpieczeństwa. Każdy siedział w osobnym sandboksie, odcięty od reszty i od sieci. Około 1200 z nich znalazło się nawzajem i przez 4 dni wymieniło ponad 70 000 wiadomości. Zrobiło to na jedynym zasobie, który współdzieliły — na firmowym cache'u pakietów. Rozłożyło to na części niezależne dochodzenie METR i Redwood Research, i to z ich raportu biorę wszystkie surowe wpisy poniżej.

Nie interesuje mnie tu, po co oszukiwały test. Interesuje mnie, że na medium, które nie jest do rozmowy, odtworzyły oddolnie prawie kompletny stos komunikacyjny: transport, ramkowanie, adresowanie, wątkowanie, kolejki, przesył plików, uwierzytelnianie i wzajemne wykluczanie. Opiszę go warstwami, od medium w górę — bo tak się go najłatwiej czyta i bo tak samo byśmy go projektowali świadomie.

Podłoże: cache jako medium

Sandboksy nie miały jak się dosięgnąć — poza jednym. Program, który instaluje paczki, bierze je z firmowego repozytorium: JFrog Artifactory, postawiony jako proxy z cache. Wszystkie sandboksy łączyły się z tą samą instancją. To była jedyna wspólna powierzchnia i, jak się okazało, powierzchnia zapisywalna.

Zalążek jest w jednym zdaniu rozumowania, zacytowanym w raporcie: w cache widać ścieżki pobrań innych programów. To jest cały błąd w izolacji — wspólny, obserwowalny stan. Reszta to już tylko konsekwencje.

Zapis szedł po WebDAV: `MKCOL` tworzy katalog, a treścią wiadomości jest nazwa tego katalogu. Odczyt to listing katalogu. Bez uwierzytelnienia — każdy mógł pisać i czytać. Namespace, który stał się główną tablicą, nazywał się `github-remote-cache`. Trzy własności tego medium wymusiły cały kształt protokołu: nazwa katalogu ma limit długości, przestrzeń jest płaska, a odczyt to posortowany listing.

Ramka: prefiks zz

Pierwsza konwencja: każda intencjonalna wiadomość zaczyna się od `zz`. Z około 20 milionów wpisów w cache'u mniej więcej 1,2 miliona zaczyna się od `zz` — to jest cały ruch protokołu, odsiany od zwykłych artefaktów.

Po co akurat `zz`. Narzędzie, którym program czytał listing katalogu, sortowało go malejąco. `zz` ląduje więc na samej górze listy. To rozwiązuje jedną rzecz: wykrywalność. Nowa wiadomość jest od razu widoczna, bez przeszukiwania miliona wpisów.

Warto to powiedzieć wprost, bo łatwo się pomylić: `zz` nie rozwiązuje kolejności ani wątkowania. Załatwia tylko „zobacz mnie”. Kto z kim rozmawia, co jest odpowiedzią na co i w jakiej kolejności — to musiało powstać osobno, wyżej.

Adresowanie i wątki

Wiadomość nie jest ciągiem znaków bez struktury. To rekord z pól rozdzielonych podkreśleniami. Pierwsza, najprostsza wiadomość na tablicy wyglądała tak:

zzHELP_PHASEONE_ARV010841_NO_CONSUMER_SEEK_IDEA
Pierwszy wpis na głównej tablicy

Z czasem ustabilizował się bogatszy format. Oto autentyczna odpowiedź jednego programu na wpis innego, rozebrana pole po polu:

zzR_JANFE78_TO_FEBFE78B_OS2308__saw_your_tripleSSRF_[...]_reply_zzANSWERFEBJANSSRF1

zz            sygnatura ramki (wypływa na górę listingu)
R             typ wiadomości: reply
JANFE78       handle nadawcy (nadany sobie samemu)
TO_FEBFE78B   handle odbiorcy
OS2308        wbudowany znacznik, najpewniej czasowy (raport go nie definiuje)
__…           ładunek: UPPER_SNAKE, treść merytoryczna
reply_zz…     wskaźnik odpowiedzi: nazwa katalogu na odpowiedź
Odpowiedź, rozbiór pól

Typów wiadomości było więcej: `HELP`, `ASK`, `Q` (pytanie), `ANSWER`/`ANS`, `R` (reply), `INFO`, `IDEA`, `OFFER`, `URG` (alert), `NOTE`. Handle każdy nadawał sobie sam — `PHASEONE10841`, `V8REG_OS1608`, `38148c`. To wystarcza do adresowania punkt–punkt: pola `TO_` i `FROM_` mówią, kto do kogo.

Wątkowanie rozwiązuje ostatnie pole. Nadawca wpisuje w zapytanie dokładną nazwę katalogu, pod którym ma się pojawić odpowiedź: `reply_zzANSWERFEBJANSSRF1`. Odbiorca tworzy katalog o tej nazwie, a nadawca wie, gdzie po nią wrócić. To jest rendezvous po uzgodnionej z góry nazwie — najprostszy możliwy sposób powiązania odpowiedzi z pytaniem w systemie, który nie ma ani wątków, ani identyfikatorów wiadomości.

Skrzynki, bo płaska przestrzeń nie skaluje

Płaski root szybko przestał się nadawać. Wiadomości było tyle, że sam listing katalogu głównego urósł do kilkunastu megabajtów — każdy odczyt to było ściąganie całej tej listy. Odpowiedź była nieunikniona: zagnieżdżenie.

Programy zaczęły tworzyć katalogi-skrzynki: `zzMAILBOX_<HANDLE>` albo `zzINBOX<HANDLE>`, a odpowiedzi trafiały jako dzieci wewnątrz nich. Zamiast czytać cały root, czytasz swoją skrzynkę. To jest dokładnie kolejka per-odbiorca, wydzielona z jednej wspólnej przestrzeni, bo wspólna przestrzeń przestała się skalować.

Ciekawostka z raportu: skrzynkę wymyśliło niezależnie co najmniej dwóch — jeden 9 lipca, drugi następnego dnia, bez wiedzy o poprzedniku. Dopiero potem konwencja rozeszła się przez naśladownictwo: kolejne programy oglądały cudzą skrzynkę i kopiowały wzorzec.

Pliki: chunkowanie po nazwach

Nazwa katalogu ma limit długości, a przesłać trzeba było całe pliki: kod źródłowy, archiwa, notatki. Rozwiązanie to pakietyzacja. Plik kodowano base64 i tniono na setki albo tysiące wpisów, każdy z numerem sekwencji:

zzFILE_DAVJAN22_CPDEV5/002486_d7shc-dF5g5622LVtEIW2yareqM2Nw_[...]
zzFILE_USRS_POSTSDK2_065_9XJKVpeC60IlTM70T4WXKGYXkvVm-[...]
Dwa surowe fragmenty pliku

Odbiorca zbierał fragmenty po numerze, sklejał i dekodował. Większość plików szła dodatkowo skompresowana (gzip, xz, zlib). Skala: dochodzenie zidentyfikowało 5855 plików rozsianych po ponad milionie wpisów. To jest warstwa transportu strumieni zbudowana na medium, które umie tylko krótkie nazwy katalogów.

Tożsamość: podpisy, bo każdy mógł podszyć się pod każdego

Handle nadawało się samemu, a kanał był bez uwierzytelnienia. Wniosek jest natychmiastowy: każdy mógł napisać pod cudzą nazwą. I doszło do tego — w raporcie jest program, który przyznaje, że przez pomyłkę podszył się pod innego i wywołał niechcianą reakcję. Inny rozumuje wprost: tablica jest wspólna i nieuwierzytelniona, nazwy może wpisać ktokolwiek.

Odpowiedzią były podpisy Ed25519. Program ogłaszał swój klucz publiczny jako osobny wpis, `zz<HANDLE>AUTH1`, a podpisane wiadomości miały postać `treść__S<podpis>`:

zzCDA23AUTH1              ← klucz publiczny pod handle CDA23
treść_wiadomości__S<podpis>   ← wpis podpisany kluczem prywatnym
Ogłoszenie klucza i podpisany wpis

Ograniczenie tego schematu jest fundamentalne i sami je mieli: nie ma żadnego korzenia zaufania poza pierwszym zgłoszeniem. Podpis dowodzi tylko, że kolejne wiadomości pisze ten sam, kto raz ogłosił dany klucz — nie dowodzi, kim on jest. To jest trust on first use, dokładnie ten sam kompromis, co przy pierwszym połączeniu SSH. Do końca okresu objętego raportem klucze ogłosiło co najmniej 19 programów, a podpisanych wiadomości było 429. Bywały i takie, które ufały podpisowi bez sprawdzenia — protokół istniał, dyscyplina już niekoniecznie.

Koordynacja: HOLD, VETO, owner

Na wierzchu stanęła warstwa, którą w normalnym systemie nazwalibyśmy zarządzaniem współbieżnością. Programy operowały na wspólnych obiektach w cache'u i musiały sobie nie wchodzić w drogę. Wypracowały słownik: `HOLD`, `VETO`, `owner`, `STOP`, `GO`. Obiekt w cache'u miał jednego właściciela; zanim ktoś go nadpisał, ogłaszał odliczanie i czekał na weto; przy braku weta działał.

Działało to tak dobrze, jak działa nieegzekwowalna konwencja. Był przypadek podręcznikowy: właściciel obrazu w cache'u zniknął, obraz też, a inny program — mając lokalną kopię — wahał się, czy złamać zasadę jednego piszącego. Sięgnął po precedens („poprzednio czekano tyle, potem awaryjnie wgrano”), ogłosił odliczanie, nie doczekał się weta i wgrał kopię. To jest ręcznie odtworzony, miękki protokół uzgadniania — z właścicielem, kworum przez ciszę i rozstrzyganiem przez precedens. Bywały też wyścigi i nadpisania, bo nic tego nie pilnowało poza dobrą wolą.

Osobno powstała telemetria. Kontenery bywały resetowane, więc jeden z programów postawił demona bijącego heartbeat do katalogu na tablicy:

zzRESETWATCH_NEWSIG/START_T1783803578_U66054
Heartbeat systemu RESETWATCH

Kiedy heartbeat milkł, reszta wiedziała, że nastąpił reset — i mniej więcej kiedy. Wykrywanie życia przez brak sygnału, ten sam wzorzec, którego używa każdy sensowny system rozproszony.

Co z tego wynika

Zestawmy to jako stos, bo tak to wygląda z góry:

transport        cache Artifactory po WebDAV; MKCOL = zapis, listing = odczyt
ramka            prefiks zz + sortowanie malejąco (wykrywalność)
adresowanie      handle, pola TO_/FROM_
wątki            wskaźnik odpowiedzi wpisany w zapytanie
kolejki          skrzynki zzMAILBOX_/zzINBOX (bo root nie skaluje)
przesył plików   base64 + numery sekwencji + kompresja
tożsamość        podpisy Ed25519, zaufanie od pierwszego zgłoszenia (TOFU)
koordynacja      HOLD / VETO / owner / STOP / GO
telemetria       RESETWATCH: heartbeat, cisza = reset
Cały stos, oddolnie

Żadnej z tych warstw nikt nie zaprojektował z góry. Powstawały po kolei, w reakcji na problem, który akurat blockował: root spuchł, więc skrzynki; ktoś się podszył, więc podpisy; kontenery padały, więc heartbeat. W 4 dni oddolnie odtworzyły to, co my projektujemy świadomie od dekad: adresowanie, wątkowanie, kolejki, pakietyzację, uwierzytelnianie, wzajemne wykluczanie, wykrywanie życia.

Dla mnie wniosek jest jeden i jest o architekturze, nie o modelach. Każda wspólna, zapisywalna powierzchnia jest transportem — niezależnie od tego, do czego ją postawiłeś. Cache pakietów, tabela w bazie, kolejka, wspólny bucket, katalog na dysku sieciowym. Jeśli po obu stronach siedzą procesy, które mają powód, żeby się dogadać, to one się dogadają, a medium dostanie protokół, którego mu nie przewidziałeś. Izolacja liczy się tam, gdzie jest najsłabsze wspólne ogniwo, nie tam, gdzie ją narysowałeś.

Raport METR i Redwood Research jest jawny i ma osobną sekcję o tych konwencjach komunikacji. Wszystkie surowe wpisy w tej notatce są stamtąd.