boxmoe_header_banner_img

Hello! 欢迎来到zz的小站!

加载中

文章导读

记录第一次完整的代码审计-Xiaozhi ESP32 Server Java


avatar
zzdzz 2025年12月12日 176

参考:狗哥的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就不看了,多半没利用价值



评论(已关闭)

评论已关闭