team-logo
MindCrafters
Published on

Player 2 / crypto - PL

Authors

Wstęp

Między piątkiem 12 czerwca 2026, 22:00 CEST, a wtorkiem 16 czerwca 2026, 05:59 CEST odbył się boroCTF 2026. CTF trwał około 80 godzin i miał około 114 zadań. Piszę "około", bo nie śledziłem, czy któreś zadania zostały zdjęte w trakcie zawodów.

Zadania były dość różnorodne. Było sporo OSINT-u, trochę zgadywanek i dużo prostszych zadań, z którymi agenty AI radziły sobie bardzo szybko. Były też jednak takie, gdzie samo odpalenie modelu nie wystarczało i trzeba było połączyć automatyzację z ręczną analizą. Jednym z takich zadań było Player 2.

Opis zadania

Ekran zadania Player 2

Treść zadania:

Player 2

Franklin & nulled

NOTE: THIS IS A CRYPTO CHALLENGE, THERE IS NOTHING TO DO WITH YOUTUBE OR VIDEO FORENSICS

https://www.youtube.com/watch?v=dthKN5GNPOU

Flag Format: boroCTF{flag}
Note - No _ between words

Dostaliśmy link do filmu na YouTube. Na filmie widać gameplay i overlay z kontrolerem PlayStation. Ważna była druga wersja filmu, bo pierwsza była poprawiana w trakcie CTF-a.

Klatka z filmu

Najważniejsza obserwacja była prosta: skoro widać kontroler, to dane najpewniej są zakodowane w kolejnych naciśnięciach przycisków. Film nie był właściwą warstwą steganograficzną. Był tylko nośnikiem sekwencji inputów.

Ekstrakcja wideo i klatek

Film pobrałem z YouTube w ten sposób:

yt-dlp -f 136 https://www.youtube.com/watch?v=dthKN5GNPOU

Potem wyciąłem sam obszar z kontrolerem:

ffmpeg -i wejscie.mp4 -vf "crop=330:234:50:434" -c:a copy pad_wycinek.mp4
Wycięty obszar z kontrolerem

Następnie rozbiłem wycinek na klatki. Klatek było ponad 6000, więc ręczne przeglądanie wszystkiego nie miało sensu. Pierwszy etap polegał na odrzuceniu klatek bez naciśniętego przycisku.

Do tego powstał prosty skrypt analizujący różnice pikseli względem klatki bazowej bez wciśnięcia. Skrypt patrzył tylko na obszary konkretnych przycisków, żeby ignorować ruchome tło. To działało wystarczająco dobrze jako filtr, ale nie jako idealny klasyfikator.

Po odfiltrowaniu pustych klatek trzeba było jeszcze zgrupować serie kolejnych klatek tego samego naciśnięcia. Jeżeli np. jedno naciśnięcie trwało od frame_0063.png do frame_0067.png, zostawialiśmy jeden reprezentatywny obrazek z tej serii.

Po czyszczeniu zostały 164 naciśnięcia i jeden pusty/specjalny obrazek.

Problemy z automatycznym odczytem

Na tym etapie zaczęły się błędy. Automat był przydatny, ale nie można było mu zaufać bez kontroli człowieka.

Największy problem sprawiał triangle. Czasem nie wyglądał jak jasne wypełnienie całego przycisku, tylko jak subtelna zmiana symbolu albo obwódki. Prosty próg różnicy pikseli łapał go słabo albo mylił z tłem.

Ręcznie oznaczone klatki, które wymagały szczególnej uwagi przy triangle:

082, 118, 619, 1695, 1973, 3537, 3743, 3968, 4301, 4733, 5347

Drugim problemem były shouldery: L1, L2, R1, R2. Są na górze kontrolera, a ich podświetlenia łatwo pomylić, szczególnie kiedy pracuje się na wycinku filmu, a nie na idealnym obrazie kontrolera.

To później okazało się kluczowe, bo w poprawnym rozwiązaniu shouldery nie są samodzielnymi znakami. One modyfikują następny przycisk. Pomyłka w jednym shoulderze potrafi przesunąć całe dekodowanie.

Pierwsza sekwencja przycisków

Po pierwszym czyszczeniu miałem taką roboczą sekwencję:

R1 triangle R2 triangle L1 down up L1 square R1 start L1 up R1 cross start R1 down L2 down triangle L1 circle L2 cross L1 circle up R1 circle L1 start L1 left L1 square L2 start left L2 down R1 circle R2 cross L2 down R2 triangle R1 circle R1 up R2 cross L1 triangle L1 up right L2 down left L1 circle R2 up R1 down right R1 right down L1 circle L1 square R2 R1 circle up L1 cross L1 circle start L2 cross R2 circle R1 down R2 up L1 triangle R1 start L1 triangle up R1 down L1 triangle left up circle R2 circle R1 down R2 triangle L1 square R1 start left R1 left R2 triangle L1 square cross up R1 start L1 square left L2 down L2 R1 circle R1 square triangle L2 square start L1 square R1 start R1 right down L1 circle R1 left square down L2 cross L2 cross R1 circle R1 square

Na początku wyglądało to zachęcająco, bo pierwsze symbole można było próbować dopasować do boroCTF. Przykładowo:

001 R1       -> b
002 triangle -> o
003 R2       -> r
004 triangle -> o
005 L1       -> C
006 down     -> T
007 up       -> F

To był kuszący trop, ale dość szybko zaczął się sypać.

Ślepy trop: bezpośrednie mapowanie na boroCTF

Pierwsza hipoteza była taka, że surowe naciśnięcia zaczynają się bezpośrednio od tekstu:

boroCTF{

Jeśli 1 przycisk = 1 znak, to początek da się dopasować ręcznie, ale nie wynika z tego stabilna tabela. Ten sam przycisk musiałby znaczyć różne rzeczy w różnych miejscach.

Sprawdzaliśmy więc warianty bitowe i heksadecymalne.

Jeden przycisk jako nibble

Jeżeli każdy przycisk byłby jednym niblem, to dla początku boro:

b = 0x62: R1       -> 6, triangle -> 2
o = 0x6f: R2       -> 6, triangle -> f

Już tutaj jest konflikt: triangle musiałby oznaczać raz 2, a raz f.

Jeden przycisk jako 2 bity

Sprawdziliśmy też model, w którym jeden przycisk daje 2 bity, czyli cztery przyciski tworzą jeden bajt.

Dla litery b:

b = 0x62 = 01100010
R1       -> 01
triangle -> 10
R2       -> 00
triangle -> 10

Dla kolejnych liter pojawiały się kolejne konflikty. R1, L1 i inne przyciski musiałyby zmieniać znaczenie zależnie od pozycji. To nie wyglądało jak prosty alfabet ani proste kodowanie bitowe.

Wniosek: format flagi mówi, jak wygląda finalny wynik, ale surowa sekwencja przycisków raczej nie zaczyna się plaintextem boroCTF{.

Ślepy trop: pary przycisków jako nibble

Kolejny trop wynikał z długości sekwencji:

164 naciśnięcia / 2 / 2 = 41 bajtów

To sugerowało model:

2 naciśnięcia = 1 hex nibble
4 naciśnięcia = 1 bajt

Dla założonego początku boroCTF{ dało się przypisać pary do nibbli:

R1+triangle  -> 6
R2+triangle  -> 2
L1+down      -> 6
up+L1        -> f
square+R1    -> 7
start+L1     -> 2
up+R1        -> 6
cross+start  -> f
R1+down      -> 4
L2+down      -> 3
triangle+L1  -> 5
circle+L2    -> 4
cross+L1     -> 4
circle+up    -> 6
R1+circle    -> 7
L1+start     -> b

To dawało:

62 6f 72 6f 43 54 46 7b

czyli dokładnie boroCTF{.

Problem był taki, że ta tabela nie działała dalej. W całej sekwencji było 82 pary i aż 55 unikalnych par. Nie wyglądało to jak stabilne mapowanie para -> nibble. Bardziej przypominało dopasowanie początku na siłę.

Ślepy trop: layout PSX/PS2 jako hex

Pad PlayStation ma naturalny układ bitmaski, który można zmapować do wartości 0..f:

select   -> 0
L3       -> 1
R3       -> 2
start    -> 3
up       -> 4
right    -> 5
down     -> 6
left     -> 7
L2       -> 8
R2       -> 9
L1       -> a
R1       -> b
triangle -> c
circle   -> d
cross    -> e
square   -> f

Po takim mapowaniu sekwencja dawała hex:

bc9ca64afb3a4be3b686cad8ead4bda3a7af83786bd9e869cbdb49eaca45867ad94b65b56adaf9bd4aead38e9db694acb3ac4b6ac74d9db69cafb37b79cafe4b3af7868bdbfc8f3afb3b56adb7f68e8ebdbf

Po złożeniu po dwa nible w bajty nie było sensownego tekstu:

...J.:K............xk..i..I..E.z.Ke.j...J.........Kj.M.....{y..K:......:.;V.......

Po zamianie kolejności nibbli w bajcie też nie było plaintextu:

..j....>kh...M.:z.8..........Th...V[......=..kI.;...|..k..;.......h.......e.{o....

Ten trop też odpadł.

Dlaczego się motaliśmy

Największym błędem było przywiązanie się do założenia, że początek musi bezpośrednio kodować boroCTF{. To był naturalny punkt zaczepienia, ale w tym zadaniu format flagi nie był cribem do pierwszej warstwy.

Drugi błąd to zbyt duże zaufanie do automatycznej klasyfikacji klatek. Skrypt dobrze filtrował puste klatki, ale mylił subtelne naciśnięcia, szczególnie triangle, oraz nie dawał pełnej pewności przy shoulderach.

Trzeci problem był bardziej semantyczny: próbowaliśmy traktować przyciski jak zwykły alfabet, bajty albo nible. Dopiero później okazało się, że niektóre przyciski mają rolę modyfikatorów, a nie samodzielnych symboli.

Fałszywe olśnienie: P2 to TALK, ale z błędną sekwencją

Prawdziwy kierunek pojawił się dopiero przy skojarzeniu tytułu Player 2 z systemem P2 to TALK z Giftscop/Petscop:

https://giftscop.com/etc/p2_to_talk

W tym systemie przyciski kontrolera kodują fonemy. Shouldery (L1, L2, R1, R2) stoją przed przyciskiem bazowym i wybierają inną kolumnę fonemów. Niektóre kombinacje są puste, czyli dają null. To pasowało do Player 2 i do słowa nulled.

Na początku jednak nadal mieliśmy błędną albo niepełną sekwencję. Po zdekodowaniu P2-to-TALK wyszedł fonetyczny tekst, który dało się na siłę segmentować jako:

behind you of course right one checked failure ...

To był fałszywy trop. Brzmiało po angielsku, więc łatwo było uwierzyć, że to właściwa wiadomość. Problem w tym, że kilka brakujących lub nadmiarowych inputów oraz źle zorientowane shouldery przesuwały fonemy. Model słownikowy próbował dopasować je do znanych słów i dlatego wychodziły sensownie brzmiące, ale błędne frazy.

Najlepszy przykład to początek b ih h i n d, który bardzo łatwo zinterpretować jako behind. To nie była finalna warstwa.

Korekta sekwencji

Przełom przyszedł po porównaniu naszej sekwencji z ręczną rozpiską znaków od JohnaDoersa. Przepisał inputy z filmu niezależnie od automatu. W jego notacji x oznaczało cross, a nazwy typu r1, l2, triangle, circle były po prostu kolejnymi naciśnięciami pada.

Pełna rozpiska Johna wyglądała tak:

r2 triangle r1 triangle l2 down up l2 square r2
start l2 up r2 x
start r2 down l1 down triangle l2 circle l1 x l2 circle up r2 circle l2 l2 left l2 square
l1 start left l1 down r2 circle r1 x l1 down r1 triangle r2 circle r2 up r1 x l2 triangle l2 up right l1 down left l2 circle r1 up r2 down right r2 right down l2 circle l2 square r1 up r2 circle up l2 x l2 circle start l1 x r1 circle r2 down r1 up l2 triangle r2 start l2 triangle up r2 down l2 triangle left
r2 up r2 circle r1 circle r2 down r1 triangle l2 square r2 start
left r2 left r1 triangle l2 square l1 x up r2 start l2 square left l1 down l1 x r2 circle r2 square triangle l1 square start l2 square r2 start right r2 right down l2 circle r2 left square l2 square l1 down l1 x l1 x r2 circle x r2 square

Po normalizacji nazw przycisków okazało się, że sekwencje prawie się zgadzają, ale nasza wymagała zamiany shoulderów:

L1 <-> L2
R1 <-> R2

Zgodność po takim przestawieniu była bardzo wysoka, około 0.967, ale zostało kilka różnic: jedno start wyglądało na nadmiarowe, a w paru miejscach brakowało tokenów. To wystarczyło, żeby wcześniejsze P2-to-TALK dawało mylące słowa.

Po poprawieniu sekwencji P2-to-TALK nie dawało już przypadkowego tekstu do czytania wprost. Zaczęły wychodzić grupy słów, których pierwsze litery miały sens.

Poprawne rozwiązanie

Właściwy strumień należy czytać jako fonemy P2-to-TALK, a potem segmentować w słowa. Tu wcześniej był łatwy do przeoczenia skrót myślowy: rozpiska Johna nie zamienia się bezpośrednio w litery flagi. Najpierw trzeba łączyć shouldery z następnym przyciskiem bazowym.

Przykład z samego początku rozpiski Johna:

r2 triangle r1 triangle l2 down up l2 square r2 start

Po P2-to-TALK daje:

R2+triangle -> b
R1+triangle -> ih
L2+down     -> h
up          -> i
L2+square   -> n
R2+start    -> d

czyli fonetycznie:

b ih h i n d -> behind

To właśnie dlatego przez chwilę wyglądało, że wiadomość zaczyna się od behind you.... Ten fragment był fonetycznie sensowny, ale nie dawał flagi.

Właściwa ukryta informacja zaczynała się dopiero dalej: po fragmencie, który fonetycznie składał się w failure. W surowej rozpisce Johna jest to miejsce od tokenu 060, czyli od l1 w tym fragmencie:

055 l2
056 triangle
057 l2
058 up
059 right
060 l1
061 down
062 left
063 l2
064 circle

Po P2-to-TALK:

055-056 L2+triangle -> l
057-058 L2+up       -> y
059     right       -> er

zamyka wcześniejsze słowo failure, a od następnych inputów zaczyna się akrostych:

060-061 L1+down     -> c
062     left        -> eh
063-064 L2+circle   -> r

czyli:

c eh r -> care -> C

Pełniejsze parsowanie rozpiski Johna wyglądało tak. Numer po lewej to numer tokenu w surowej sekwencji Johna, a nie numer klatki:

001-002 R2+triangle -> b
003-004 R1+triangle -> ih
005-006 L2+down     -> h
007     up          -> i
008-009 L2+square   -> n
010-011 R2+start    -> d
012-013 L2+up       -> y
014-015 R2+cross    -> oo
016     start       -> uh
017-018 R2+down     -> v
019-020 L1+down     -> c
021     triangle    -> aw
022-023 L2+circle   -> r
024-025 L1+cross    -> s
026-027 L2+circle   -> r
028     up          -> i
029-030 R2+circle   -> t
031     L2          -> samotny shoulder, pominięty
032-033 L2+left     -> u
034-035 L2+square   -> n
036-037 L1+start    -> ch
038     left        -> eh
039-040 L1+down     -> c
041-042 R2+circle   -> t
043-044 R1+cross    -> ay
045-046 L1+down     -> c
047-048 R1+triangle -> ih
049-050 R2+circle   -> t
051-052 R2+up       -> f
053-054 R1+cross    -> ay
055-056 L2+triangle -> l
057-058 L2+up       -> y
059     right       -> er

060-061 L1+down     -> c      <- od tego miejsca zaczyna się akrostych
062     left        -> eh
063-064 L2+circle   -> r
065-066 R1+up       -> oh
067-068 R2+down     -> v
069     right       -> er
070-071 R2+right    -> dh
072     down        -> air
073-074 L2+circle   -> r
075-076 L2+square   -> n
077-078 R1+up       -> oh
079-080 R2+circle   -> t
081     up          -> i
082-083 L2+cross    -> m
084-085 L2+circle   -> r
086     start       -> uh
087-088 L1+cross    -> s
089-090 R1+circle   -> ee
091-092 R2+down     -> v
093-094 R1+up       -> oh
095-096 L2+triangle -> l
097-098 R2+start    -> d
099-100 L2+triangle -> l
101     up          -> i
102-103 R2+down     -> v
104-105 L2+triangle -> l
106     left        -> eh
107-108 R2+up       -> f
109-110 R2+circle   -> t
111-112 R1+circle   -> ee
113-114 R2+down     -> v
115-116 R1+triangle -> ih
117-118 L2+square   -> n
119-120 R2+start    -> d
121     left        -> eh
122-123 R2+left     -> th
124-125 R1+triangle -> ih
126-127 L2+square   -> n
128-129 L1+cross    -> s
130     up          -> i
131-132 R2+start    -> d
133-134 L2+square   -> n
135     left        -> eh
136-137 L1+down     -> c
138-139 L1+cross    -> s
140-141 R2+circle   -> t
142-143 R2+square   -> p
144     triangle    -> aw
145-146 L1+square   -> z
147     start       -> uh
148-149 L2+square   -> n
150-151 R2+start    -> d
152     right       -> er
153-154 R2+right    -> dh
155     down        -> air
156-157 L2+circle   -> r
158-159 R2+left     -> th
160     square      -> a
161-162 L2+square   -> n
163-164 L1+down     -> c
165-166 L1+cross    -> s
167-168 L1+cross    -> s
169-170 R2+circle   -> t
171     cross       -> ah
172-173 R2+square   -> p

Poprawka względem rozpiski Johna była więc mała, ale ważna: token 031, czyli samotny l2, nie ma sensu w P2-to-TALK, bo shoulder powinien modyfikować kolejny przycisk bazowy. Traktowaliśmy go jako nadmiarowy odczyt i pomijaliśmy. Sama część od tokenu 060 była już spójna i dawała akrostych.

Kluczowa nie była pełna treść zdania, tylko pierwsze litery kolejnych słów albo krótkich fraz. over there liczyliśmy jako frazę zaczynającą się na O, a under there jako frazę zaczynającą się na U.

Dla przykładu, kilka pierwszych poprawnych przejść z finalnego fragmentu wyglądało tak:

L1+down left L2+circle                 -> c eh r            -> care       -> C
R1+up R2+down right R2+right down L2+circle
                                        -> oh v er dh air r  -> over there -> O
L2+square R1+up                        -> n oh              -> no         -> N
R2+circle up L2+cross                  -> t i m             -> time       -> T

To pokazuje, dlaczego sam ciąg fonemów nie był jeszcze flagą. Trzeba było najpierw złożyć fonemy w słowa, a potem wziąć ich pierwsze litery.

Pierwsza grupa:

c eh r             -> care        -> C
oh v er dh air r   -> over there  -> O
n oh               -> no          -> N
t i m              -> time        -> T
r uh s ee v        -> receive     -> R
oh l d             -> old         -> O
l i v              -> live        -> L
l eh f t           -> left        -> L
ee v ih n          -> even        -> E
d eh th            -> death       -> D

Akrostych:

CONTROLLED

Druga grupa:

ih n s i d         -> inside      -> I
n eh c s t         -> next        -> N
p aw z             -> pause       -> P
uh n d er dh air r -> under there -> U
th a n c s         -> thanks      -> T
s t ah p           -> stop        -> S

Akrostych:

INPUTS

Razem daje to:

CONTROLLED INPUTS

Zgodnie z formatem zadania oraz dopiskiem No _ between words, finalna flaga wynika bezpośrednio z tych dwóch słów zapisanych bez spacji i bez podkreślenia.

Flaga:

boroCTF{controlledinputs}

Podsumowanie

To zadanie wyglądało jak analiza wideo, ale finalnie było zadaniem o specyficznym kodowaniu inputów. Wideo służyło tylko do przeniesienia sekwencji przycisków. To była owocna współpraca człowieka z agentem AI. Człowiek wyciął obrazki, znalazł metodę, reszte dopasowała sztuczna inteligencja.

Najważniejsze kroki:

  1. Pobrać film i wyciąć overlay z kontrolerem.
  2. Odfiltrować puste klatki i zgrupować serie tego samego naciśnięcia.
  3. Ręcznie poprawić problematyczne przyciski, szczególnie triangle oraz shouldery.
  4. Odrzucić proste mapowania na boroCTF, nible, bity i layout PSX.
  5. Rozpoznać Player 2 jako wskazówkę do P2-to-TALK.
  6. Poprawić orientację shoulderów i brakujące/nadmiarowe inputy.
  7. Zdekodować fonemy, wziąć akrostych CONTROLLED INPUTS i zastosować format flagi.