参考:狗哥的https://xz.aliyun.com/news/90738
项目简介
项目地址:https://github.com/joey-zhou/xiaozhi-esp32-server-java
Xiaozhi ESP32 Server Java 是基于 Xiaozhi ESP32 项目开发的 Java 版本服务端,包含完整的前后端管理平台。该项目旨在为用户提供一个功能丰富、操作便捷的管理界面,帮助用户更好地管理设备、配置等。
考虑到企业级应用场景的需求,Java 作为一种成熟的企业级开发语言,具备更完善的生态系统支持和更强大的并发处理能力,因此我们选择开发这个 Java 版本的服务端,为项目提供更多可能性和扩展空间。
- 后端框架:Spring Boot + Spring MVC
- 前端框架:Vue.js + Ant Design
- 数据存储:MySQL + Redis
- 全局响应式:适配各种设备及分辨率
指纹:icon_hash=”-347055786″
架构分析
典型的SSM架构
MyBatis – 持久层数据访问框架
Spring – 核心容器和业务层框架
Spring MVC – 表现层Web框架
鉴权分析
先看pom.xml依赖,发现并未使用shiro或spring Security

同时目录中没有找到关键字filter
大概率是spring拦截器,搜索关键词addInterceptor
在com/xiaozhi/common/config/WebMvcConfig.java找到白名单路由

跟进authenticationInterceptor
通过注释也可发现,这是真正的拦截器代码

同时这里又设置了一份白名单
"/api/user/",
"/api/device/ota",
"/audio/",
"/uploads/",
"/ws/"
在preHandle函数,存在五处放行点,一处处分析

第一处(不行)
// 检查是否是公共路径或有@UnLogin注解
if (isPublicPath(path) || hasUnLoginAnnotation(handler)) {
return true;
}
@UnLogin先放到后面和其他白名单下是否存在敏感路由一起看
进入isPublicPath方法
private boolean isPublicPath(String path) {
return PUBLIC_PATHS.stream().anyMatch(path::startsWith);
}
只匹配前缀是否为白名单
可以尝试通过/api/user/../../admin
能否成功建议直接测试,代码中不太好判断了
这里是不行,会提示找不到路由,说明在路由判断的时候也没做规范化
{"code":404,"message":"请求的资源不存在"}
第二处(不行)
检查session正常逻辑,直接跑路
第三处(成功利用伪造任意用户)
// 尝试从Cookie中获取用户名
if (tryAuthenticateWithCookies(request, response)) {
return true;
}
看看具体实现
private boolean tryAuthenticateWithCookies(HttpServletRequest request, HttpServletResponse response) {
// 检查是否有username cookie
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("username".equals(cookie.getName())) {
String username = cookie.getValue();
if (StringUtils.isNotBlank(username)) {
SysUser user = userService.selectUserByUsername(username);
if (user != null) {
// 将用户存储在会话和请求属性中
HttpSession session = request.getSession(true);
session.setAttribute(SysUserService.USER_SESSIONKEY, user);
request.setAttribute(CmsUtils.USER_ATTRIBUTE_KEY, user);
CmsUtils.setUser(request, user);
return true;
}
}
break;
}
}
}
return false;
}
大概流程就是找cookie中是否有username,然后带入去数据库查,如果有就直接添加session会话信息
非常逆天的逻辑
伪造可查看别人的数据

第四处(存在,但是没找到绑定wxOpenId的资产)
// 尝试从微信登录信息中获取用户
if (tryAuthenticateWithWechat(request, response)) {
return true;
}
分析具体实现方法
private boolean tryAuthenticateWithWechat(HttpServletRequest request, HttpServletResponse response) {
// 从请求头或Cookie中获取微信登录凭证
String wxOpenId = getWechatOpenId(request);
String wxSessionKey = getWechatSessionKey(request);
if (StringUtils.isNotBlank(wxOpenId)) {
// 检查session中是否已有与此openid关联的用户
HttpSession session = request.getSession(true);
SysUser sessionUser = (SysUser) session.getAttribute(SysUserService.USER_SESSIONKEY);
// 如果session中已有用户,且有相同的openid,直接使用
if (sessionUser != null && wxOpenId.equals(sessionUser.getWxOpenId())) {
request.setAttribute(CmsUtils.USER_ATTRIBUTE_KEY, sessionUser);
CmsUtils.setUser(request, sessionUser);
return true;
}
// 否则查询数据库
SysUser user = userService.selectUserByWxOpenId(wxOpenId);
if (user != null) {
// 将用户存储在会话和请求属性中
session.setAttribute(SysUserService.USER_SESSIONKEY, user);
request.setAttribute(CmsUtils.USER_ATTRIBUTE_KEY, user);
CmsUtils.setUser(request, user);
// 将微信会话信息也存入session
session.setAttribute("wx_session_key", wxSessionKey);
session.setAttribute("wx_openid", wxOpenId);
return true;
}
}
return false;
}
感觉还是很逆天,虽然获取wxOpenId和wxSessionKey,但是貌似没对wxSessionKey做校验,wxOpenId不在session中还可以到数据库去查,如果存在信息泄露wxOpenId,仍能进行鉴权绕过。
事实是存在,可见后文
值从请求头或cookie获取
第五处(存在JWT硬编码)
// 尝试从token中获取用户
if (tryAuthenticateWithToken(request, response)) {
return true;
}
明显是jwt

private SecretKey getSecretKey() {
String secret = configSecret != null ? configSecret : DEFAULT_SECRET;
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
如果配置文件没设置,就用默认key
xiaozhi_jwt_secret_key_must_be_at_least_256_bits_long_for_hs256
一处jwt伪造
白名单敏感信息
"/api/user/",
"/api/device/ota",
"/audio/",
"/uploads/",
"/ws/"
"/api/user/login",
"/api/user/register",
"/api/device/ota",
"/audio/**",
"/uploads/**",
"/ws/**",
// 添加 swagger 相关路径
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-resources/**",
"/webjars/**"
之前提到他存在2份白名单
很明显,/api/user/怎么看都挺危险
/api/user/
全局搜/api/user ,可以看到不仅是注册登录,还有一堆接口

/api/user/query?username=admin 单个用户信息获取
/**
* 用户信息查询
*
* @param username 用户名
* @return 用户信息
*/
@GetMapping("/query")
@ResponseBody
@Operation(summary = "根据用户名查询用户信息", description = "返回用户信息")
public ResultMessage query(@Parameter(description = "用户名") String username) {
try {
SysUser user = userService.query(username);
ResultMessage result = ResultMessage.success();
result.put("data", user);
return result;
} catch (Exception e) {
logger.error(e.getMessage(), e);
return ResultMessage.error();
}
}

/api/user/queryUsers 所有用户信息获取
/**
* 查询用户列表
*
* @param user 查询条件
* @return 用户列表
*/
@GetMapping("/queryUsers")
@ResponseBody
@Operation(summary = "根据条件查询用户信息列表", description = "返回用户信息列表")
public ResultMessage queryUsers(SysUser user, HttpServletRequest request) {
try {
PageFilter pageFilter = initPageFilter(request);
List<SysUser> users = userService.queryUsers(user, pageFilter);
ResultMessage result = ResultMessage.success();
result.put("data", new PageInfo<>(users));
return result;
} catch (Exception e) {
logger.error(e.getMessage(), e);
return ResultMessage.error();
}
}

/api/user/update 用户信息修改(包括改密码)
/**
* 用户信息修改
*
* @param loginRequest 包含用户信息的请求体
* @return 修改结果
*/
@PostMapping("/update")
@ResponseBody
@Operation(summary = "修改用户信息", description = "返回修改结果")
public ResultMessage update(@RequestBody Map<String, Object> loginRequest) {
try {
String username = (String) loginRequest.get("username");
String email = (String) loginRequest.get("email");
String tel = (String) loginRequest.get("tel");
String password = (String) loginRequest.get("password");
String name = (String) loginRequest.get("name");
String avatar = (String) loginRequest.get("avatar");
Integer tokenLimit = (Integer) loginRequest.get("tokenLimit");
String tokenNotify = (String) loginRequest.get("tokenNotify");
SysUser userQuery = new SysUser();
if (StringUtils.hasText(username)) {
userQuery = userService.selectUserByUsername(username);
if (ObjectUtils.isEmpty(userQuery)) {
return ResultMessage.error("无此用户,更新失败");
}
}
if (StringUtils.hasText(email)) {
// 检查邮箱是否被其他用户使用
SysUser existingUser = userService.selectUserByEmail(email);
if (!ObjectUtils.isEmpty(existingUser) && !existingUser.getUserId().equals(userQuery.getUserId())) {
return ResultMessage.error("邮箱已被其他用户绑定,更新失败");
}
userQuery.setEmail(email);
}
if (StringUtils.hasText(tel)) {
// 检查手机号是否被其他用户使用
SysUser existingUser = userService.selectUserByTel(tel);
if (!ObjectUtils.isEmpty(existingUser) && !existingUser.getUserId().equals(userQuery.getUserId())) {
return ResultMessage.error("手机号已被其他用户绑定,更新失败");
}
userQuery.setTel(tel);
}
if (StringUtils.hasText(password)) {
String newPassword = authenticationService.encryptPassword(password);
userQuery.setPassword(newPassword);
}
if (StringUtils.hasText(avatar)) {
userQuery.setAvatar(avatar);
}
if (StringUtils.hasText(name)) {
userQuery.setName(name);
}
// if (!StringUtils.hasText(avatar) && StringUtils.hasText(name)) {
// userQuery.setAvatar(ImageUtils.GenerateImg(name));
// }
if (0 < userService.update(userQuery)) {
// 返回更新后的完整用户信息,供前端使用
SysUser updatedUser = userService.selectUserByUsername(username);
return ResultMessage.success(updatedUser);
}
return ResultMessage.error();
} catch (Exception e) {
logger.error(e.getMessage(), e);
return ResultMessage.error();
}
}
根据代码,我们可以传递以下字段(全部可选,但至少要有一个字段用于更新,并且如果提供了username,则username必须存在):
- username: 用户名(用于标识要更新的用户,如果提供则必须存在于系统中)
- email: 邮箱(如果提供,则检查是否被其他用户绑定)
- tel: 手机号(如果提供,则检查是否被其他用户绑定)
- password: 密码(如果提供,则会被加密后更新)
- name: 姓名(如果提供,则更新)
- avatar: 头像(如果提供,则更新)
- tokenLimit: token限制(整数,如果提供则更新,但注意代码中获取的是Integer类型,如果JSON中传递的是字符串数字,可能会转换失败)
- tokenNotify: token通知(字符串,如果提供则更新)
没有任何cookie或者记录用户信息,成功修改密码

新密码才可登录
sql注入分析(无)
只搜到一处

roleId
往上跟,直接int型,完全没办法

文件上传(存在)
之前注册账号也可见存在头像上传
直接搜上传基本上就可以

定位工具类
/**
* 文件上传方法
* @param baseDir 基础目录路径
* @param relativePath 相对路径(可为空)
* @param fileName 保存的文件名
* @param file Spring框架的MultipartFile对象,包含上传的文件数据
* @return 保存后的文件绝对路径
* @throws IOException 文件操作失败时抛出
*/
public static String uploadFile(String baseDir, String relativePath, String fileName, MultipartFile file)
throws IOException {
// 1. 检查文件大小和类型(通过assertAllowed方法实现验证)
assertAllowed(file);
// 2. 构建完整的存储路径
String fullPath = baseDir; // 初始化为基础目录
if (!relativePath.isEmpty()) {
// 如果提供了相对路径,则拼接到基础目录后
// File.separator是系统相关的路径分隔符(Windows是\,Linux是/)
fullPath = fullPath + File.separator + relativePath;
}
// 3. 创建目标目录(如果不存在)
File directory = new File(fullPath); // 创建File对象表示目录
if (!directory.exists()) {
// mkdirs()会创建所有不存在的父目录
boolean created = directory.mkdirs();
if (!created) {
// 目录创建失败时抛出异常
throw new IOException("无法创建目录: " + fullPath);
}
}
// 4. 保存上传的文件到目标位置
// 创建目标文件对象(指定目录和文件名)
File destFile = new File(directory, fileName);
// 使用try-with-resources确保流正确关闭
try (FileOutputStream fos = new FileOutputStream(destFile); // 文件输出流
InputStream inputStream = file.getInputStream()) { // 上传文件输入流
// 使用Apache Commons IO工具类复制文件内容
// 将上传文件流复制到目标文件流中
IOUtils.copy(inputStream, fos);
}
// 5. 返回保存文件的绝对路径
return destFile.getAbsolutePath();
}
唯一的校验方法assertAllowed只检查文件大小
public static void assertAllowed(MultipartFile file) {
if (file.getSize() > DEFAULT_MAX_SIZE) {
throw new IllegalArgumentException("文件大小超过限制,最大允许:" + (DEFAULT_MAX_SIZE / 1024 / 1024) + "MB");
}
}
没检查后缀
逐层查找用法

找到路由的地方,拿到后缀只为了生成文件名,完全没有校验后缀
/api/file/upload


不过没jsp解析环境,没啥用

铲子在这里爆了一个目录穿越,type确实可以控制,但是测试发现不行

铲子还报了命令执行,但是不可控,其他的一些ssrf就不看了,多半没利用价值
评论(已关闭)
评论已关闭