admin管理员组

文章数量:1532372

文章目录

  • 前言
  • 工具开发
    • 1.1 ChatGPT初探
    • 1.2 初版代码效果
  • WebGoat适配
    • 2.1 识别常量路由
    • 2.2 适配跨行定义
  • 进阶功能优化
    • 3.1 识别请求类型
    • 3.2 识别上下文值
  • 总结

前言

最近工作上遇到一个需求:提取 SpringBoot 项目中的所有路由信息,本来想着这是一个再普通不过的任务了,本着白嫖党 “拿来主义” 的一贯作风,马上到 Github 上搜索相关工具,结果发现居然没有能够有效满足我需求的开源项目……那就自己动手丰衣足食吧!

工具开发

本文的目标是通过自动化脚本一键识别、提取 Java SpringBoot 项目的所有路由信息,方便识别、梳理代码审计的工作量,并统计审计进度和覆盖率。

在 Java Web 代码审计中,寻找和识别路由是很关键的部分,路由信息直接反映了一个系统对外暴露的攻击入口。而 Controller 作为 MVC 架构中的一个组件,可以说是每个用户交互的入口点,我们可以通过 Controller 定位系统注册的路由。

一般在代码审计时都会逐个分析每个 Controller 对应的对外 API 实现,通过梳理对应的路由接口并检查对应的业务实现,能帮助我们快速的检索代码中存在的漏洞缺陷,发现潜在的业务风险。

SpringMVC 框架中注册路由的常见注解如下:

@Controller
@RestController
@RequestMapping
@GetMapping
@PostMapping
@PutMapping
@DeleteMapping
@PatchMapping

1.1 ChatGPT初探

一开始还是想偷懒,看看 ChatGPT 能不能帮我完成这项任务,结果 ChatGPT 提供了很简洁的代码,但是存在缺陷:无法识别 @Controller 类级别的父级路由并并自动拼接出完整路由,同时会导致提取的部分函数信息错乱。

import os
import re
import pandas as pd

# 正则表达式来匹配Spring的路由注解、方法返回类型、方法名称和参数
mapping_pattern = re.compile(r'@(?:Path|RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)\((.*?)\)')
method_pattern = re.compile(r'(public|private|protected)\s+(\w[\w\.\<\>]*)\s+(\w+)\((.*?)\)\s*{')
value_pattern = re.compile(r'value\s*=\s*"(.*?)"')  # 只提取value字段中的路径值,可能包含的格式 value = "/xmlReader/sec", method = RequestMethod.POST


def extract_routes_from_file_01(file_path):
    """
    当前缺陷:无法识别@Controller类级别的父级路由并并自动拼接出完整路由,同时会导致提取的部分函数信息错乱,比如XXE(函数乱序)、xlsxStreamerXXE类(路由错误)
    为数不多的开源参考项目也存在同样的问题:https://github/charlpcronje/Java-Class-Component-Endpoint-Extractor
    """
    routes = []
    with open(file_path, 'r', encoding='utf-8') as file:
        content = file.read()
        # 找到所有路由注解
        mappings = mapping_pattern.findall(content)
        methods = method_pattern.findall(content)
        # 配对路由和方法
        for mapping, method in zip(mappings, methods):
            # 使用正则表达式提取出value字段的值
            value_match = value_pattern.search(mapping)
            route = value_match.group(1).strip() if value_match else mapping.strip()
            route = route.strip('"')  # 去除路径中的引号
            route_info = {
                'route': route,
                'return_type': method[1].strip(),
                'method_name': method[2].strip(),
                'parameters': method[3].strip(),
                'file_path': file_path,
            }
            routes.append(route_info)
    return routes


def scan_project_directory(directory):
    all_routes = []
    for root, _, files in os.walk(directory):
        for file in files:
            if file.endswith('.java'):
                file_path = os.path.join(root, file)
                routes = extract_routes_from_file_01(file_path)
                if routes:
                    all_routes.extend(routes)
    return all_routes


def write_routes_to_xlsx(all_data_list):
    data = {
        "Route": [item['route'] for item in all_data_list],
        "Return Type": [item['return_type'] for item in all_data_list],
        "Method Name": [item['method_name'] for item in all_data_list],
        "Parameters": [item['parameters'] for item in all_data_list],
        "File Path": [item['file_path'] for item in all_data_list],
    }
    writer = pd.ExcelWriter('Data.xlsx')
    dataFrame = pd.DataFrame(data)
    dataFrame.to_excel(writer, sheet_name="password")
    writer.close()
    print(f"[*] Successfully saved data to xlsx")


if __name__ == '__main__':
    # project_directory = input("Enter the path to your Spring Boot project: ")
    project_directory = r'D:\Code\Java\Github\java-sec-code-master'
    routes_info = scan_project_directory(project_directory)
    write_routes_to_xlsx(routes_info)

1.2 初版代码效果

偷懒是没戏了,那就自己动手吧。。

实验代码:https://github/JoyChou93/java-sec-code。

脚本实现:

import os
import re
import pandas as pd
from colorama import Fore, init

# 配置colorama颜色自动重置,否则得手动设置Style.RESET_ALL
init(autoreset=True)

# 统计路由数量的全局变量
route_num = 1
# 正则表达式来匹配Spring的路由注解、方法返回类型、方法名称和参数
mapping_pattern = re.compile(r'@(Path|(Request|Get|Post|Put|Delete|Patch)Mapping)\(')


def write_routes_to_xlsx(all_data_list):
    """
    将路由信息写入Excel文件
    """
    data = {
        "Parent Route": [item['parent_route'] for item in all_data_list],
        "Route": [item['route'] for item in all_data_list],
        "Return Type": [item['return_type'] for item in all_data_list],
        "Method Name": [item['method_name'] for item in all_data_list],
        "Parameters": [item['parameters'] for item in all_data_list],
        "File Path": [item['file_path'] for item in all_data_list],
    }
    writer = pd.ExcelWriter('Data.xlsx')
    dataFrame = pd.DataFrame(data)
    dataFrame.to_excel(writer, sheet_name="password")
    writer.close()
    print(Fore.BLUE + "[*] Successfully saved data to xlsx")


def extract_request_mapping_value(s):
    """
    提取类开头的父级路由,通过@RequestMapping注解中的value字段的值,可能出现括号中携带除了value之外的字段,比如 method = RequestMethod.POST
    """
    pattern = r'@RequestMapping\((.*?)\)|@RequestMapping\(value\s*=\s*"(.*?)"'
    match = re.search(pattern, s)
    if match:
        if match.group(1):
            return match.group(1).strip('"')
        else:
            return match.group(2)
    else:
        return None


def get_class_parent_route(content):
    """
    提取类级别的父级路由
    注意有可能会返回None,比如java-sec-code-master里的CommandInject.java
    """
    parent_route = None
    content_lines = content.split('\n')
    public_class_line = None
    # 遍历每一行,找到 "public class" 所在的行
    for line_number, line in enumerate(content_lines, start=1):
        if re.search(r'public class', line):
            public_class_line = line_number
            break
    if public_class_line is not None:
        # 提取 "public class" 之前的行
        content_before_public_class = content_lines[:public_class_line]
        for line in content_before_public_class:
            if re.search(r'@RequestMapping\(', line):
                parent_route = extract_request_mapping_value(line)
    return parent_route, public_class_line


def extract_value_between_quotes(line):
    """
    提取字符串中第一个""中间的值,目的是提取@GetMapping("/upload")格式中的路由值(尚待解决的是部分项目的路由值是通过一个常量类集中定义的)
    """
    pattern = r'"(.*?)"'
    match = re.search(pattern, line)
    if match:
        value = match.group(1)
        return value
    else:
        return None


def extract_function_details(function_def):
    """
    从函数定义的行级代码,解析并返回一个函数的详细信息,包括返回类型、函数名、参数等
    """
    pattern = re.compile(
        r'public\s+(?:static\s+)?(\w+)\s+(\w+)\s*\((.*)\)'
    )
    # 匹配函数签名
    match = pattern.search(function_def)
    if match:
        return_type = match.group(1)  # 返回类型
        function_name = match.group(2)  # 函数名
        parameters = match.group(3)  # 参数
        return return_type, function_name, parameters
    else:
        return None, None, None


def extract_routes_from_file(file_path):
    routes = []
    with open(file_path, 'r', encoding='utf-8') as file:
        content = file.read()
        # 找到Controller注解对应的Controller类
        if re.search('@(?!(ControllerAdvice))(|Rest)Controller', content):
            parent_route, public_class_line = get_class_parent_route(content)
            content_lines = content.split('\n')
            # 提取类名定义所在行后的所有代码
            content_after_public_class = content_lines[public_class_line:]
            global route_num
            for i, line in enumerate(content_after_public_class):
                if re.search(mapping_pattern, line):
                    # 获取完整的一条路由信息
                    route = extract_value_between_quotes(line)
                    if parent_route is not None and route is not None:
                        route = parent_route + route
                    # 向下遍历找到第一行不以 @ 开头的代码,因为一个函数的定义可能包含多个注解,比如 @GetMapping("/upload") @ResponseBody
                    j = i + 1
                    while j < len(content_after_public_class) and content_after_public_class[j].strip().startswith('@'):
                        j += 1
                    method_line = content_after_public_class[j].strip()
                    # print(route)
                    # print(method_line)
                    return_type, function_name, parameters = extract_function_details(method_line)
                    # print(parameters)
                    route_info = {
                        'parent_route': parent_route,
                        'route': route,
                        'return_type': return_type,
                        'method_name': function_name,
                        'parameters': parameters,
                        'file_path': file_path,
                    }
                    routes.append(route_info)
                    print(Fore.GREEN + '[%s]' % str(route_num) + str(route_info))
                    route_num += 1
    return routes


def scan_project_directory(directory):
    all_routes = []
    for root, _, files in os.walk(directory):
        for file in files:
            if file.endswith('.java'):
                file_path = os.path.join(root, file)
                routes = extract_routes_from_file(file_path)
                if routes:
                    all_routes.extend(routes)
    return all_routes


if __name__ == '__main__':
    # project_directory = input("Enter the path to your Spring Boot project: ")
    project_directory1 = r'D:\Code\Java\Github\java-sec-code-master'
    project_directory2 = r'D:\Code\Java\Github\java-sec-code-master\src\main\java\org\joychou\controller\othervulns'
    routes_info = scan_project_directory(project_directory1)
    write_routes_to_xlsx(routes_info)

生成的 xlsx 统计表格效果:


以最后的java-sec-code-master\src\main\java\org\joychou\controller\othervulns\xlsxStreamerXXE.java为例对比下代码:

解析均无误,此项目测试完毕。同时已验证另外的开源项目测试也没有问题:https://github/yangzongzhuan/RuoYi。

WebGoat适配

开源项目:https://github/WebGoat/WebGoat,扫描此项目面临需要解决的问题有两个。

2.1 识别常量路由

路由信息由静态常量定义,而非直接通过字符串提供。

直接通过上述脚本扫描将出错:

核心是修改上述脚本的 extract_value_between_quotes 函数提取路由入口函数的路由值所对应的代码逻辑。

2.2 适配跨行定义

提取路由注解定义的代码,如果出现换行符,则会导致此注解的参数解析出现残缺,比如:

同时获取路由的入口函数的定义,暂未考虑函数定义逻辑通过多行完成,可能导致提取的函数参数缺失,同时如果注解是多行的情况下,代码是有Bug的,不能直接提取第一行非@开头的代码。

直接通过上述脚本扫描则将提取到的字段全为空。

核心是修改上述脚本的提取路由注解、入口函数定义所对应的代码逻辑。

需求新增的代码

……

def find_constant_value(folder_path, constant_name):
    """
    提取出路由的常量值
    """
    for root, dirs, files in os.walk(folder_path):
        for file in files:
            if file.endswith('.java'):
                file_path = os.path.join(root, file)
                with open(file_path, 'r', encoding='utf-8') as f:
                    content = f.read()
                    pattern = fr'static\s+final\s+String\s+{constant_name}\s*=\s*"(.*?)";'
                    match = re.search(pattern, content)
                    if match:
                        return match.group(1)
    return None


def get_path_value(line, directory):
    """
    提取出路由的值,适配通过字符串直接提供的路由值,或者通过常量提供的路由值,比如:
    @GetMapping(path = "/server-directory")、@GetMapping(path = URL_HINTS_MVC, produces = "application/json")、@GetMapping(value = "/server-directory")
    """
    pattern = r'\((?:path|value)\s*=\s*(?:"([^"]*)"|([A-Z_]+))'
    matches = re.findall(pattern, line)
    route = ''
    for match in matches:
        if match[0]:  # 提取出path为字符串的值
            route = match[0]
            # print(Fore.GREEN + route)
        elif match[1]:  # 提取出path为常量的值
            route = find_constant_value(directory, match[1])
            # print(Fore.BLUE + route)
    return route
    

def extract_routes_from_file(file_path, directory):
    routes = []
    with open(file_path, 'r', encoding='utf-8') as file:
        content = file.read()
        # 找到Controller注解对应的Controller类
        if re.search('@(?!(ControllerAdvice))(|Rest)Controller', content):
            parent_route, public_class_line = get_class_parent_route(content)
            content_lines = content.split('\n')
            # 提取类名定义所在行后的所有代码
            content_after_public_class = content_lines[public_class_line:]
            global route_num
            for i, line in enumerate(content_after_public_class):
                try:
                    if re.search(mapping_pattern, line):
                        route_define = line.strip()
                        # 如果路由映射的定义逻辑在一行代码中完全覆盖
                        if route_define.endswith(')'):
                            route_define = route_define
                        # 如果路由映射的定义逻辑在多行代码中才覆盖
                        else:
                            q = i + 1
                            while q < len(content_after_public_class) and not content_after_public_class[q].strip().endswith(')'):
                                route_define += '' + content_after_public_class[q].strip()
                                q += 1
                            route_define += '' + content_after_public_class[q].strip()
                        # print(Fore.RED + route_define)
                        # 判断下路由信息是通过字符串字节提供的,还是通过常量提供的,然后统一提取出字符串值
                        if re.search(r'\("', route_define):
                            route = extract_value_between_quotes(route_define)
                        else:
                            route = get_path_value(route_define, directory)
                        # 获取完整的一条路由信息
                        if parent_route is not None and route is not None:
                            route = parent_route + route
                        # 向下遍历找到函数的定义,此处考虑了路由注解下方可能还携带多个其它用途的注解
                        j = i + 1
                        while j < len(content_after_public_class) and not content_after_public_class[j].strip().startswith('public'):
                            j += 1
                        method_define = content_after_public_class[j].strip()
                        # 获取函数定义的行级代码,考虑函数定义可能跨越多行,需进行代码合并,获得完整的函数定义,否则可能导致函数参数提取残缺
                        q = j
                        while j < len(content_after_public_class) and not content_after_public_class[q].strip().endswith('{'):
                            q += 1
                            method_define = method_define + '' + content_after_public_class[q].strip()
                        # print(route)
                        # print(method_define)
                        return_type, function_name, parameters = extract_function_details(method_define)
                        route_info = {
                            'parent_route': parent_route,
                            'route': route,
                            'return_type': return_type,
                            'method_name': function_name,
                            'parameters': parameters,
                            'file_path': file_path,
                        }
                        routes.append(route_info)
                        print(Fore.GREEN + '[%s]' % str(route_num) + str(route_info))
                        route_num += 1
                except Exception as e:
                    print(Fore.RED + '[-]' + str(file) + ' ' + str(e))
                    continue
    return routes

扫描结果与验证:


同时已验证对于前面 java-sec-code 的项目扫描结果不影响。

进阶功能优化

3.1 识别请求类型

增加路由注解的类型识别逻辑,最终对表格增加一列,保存路由所对应的 HTTP 请求类型字段,比如 GET、POST。

为此增加了get_request_type(route_define)函数:

def get_request_type(route_define):
    """
    从路由定义的注解中,提取出API请求类型,比如GET、POST等
    """
    # print(route_define)
    if route_define.startswith('@RequestMapping'):
        # 提取@RequestMapping注解中的method字段的值
        if route_define.find('method =') > -1:
            request_type = (str(route_define.split('method =')[1]).split('}')[0].strip().replace('{', '').replace(')', '')).replace('RequestMethod.', '')
        # 未指定具体请求类型的RequestMapping注解,则默认为支持所有请求类型
        else:
            request_type = 'All'
    else:
        request_type = route_define.split('Mapping')[0][1:]
    return request_type

本扫描效果:

3.2 识别上下文值

在 Spring Boot 项目中,context 上下文配置主要用于设置应用程序的上下文路径、组件扫描路径、国际化配置、资源路径、环境变量等。这些配置通常可以在以下几个地方进行:

1、application.properties 或 application.yml 文件

这些是 Spring Boot 项目中最常用的配置文件,位于 src/main/resources 目录下,设置上下文路径:

# application.properties
server.servlet.context-path=/myapp

或者:

# application.yml
server:
  servlet:
    context-path: /myapp

2、使用环境变量或命令行参数

Spring Boot 支持通过环境变量或命令行参数覆盖配置文件中的配置,这样可以动态调整上下文配置。

此处暂时只考虑识别第一种情况,即配置文件中的上下文路径配置。

添加识别上下文的功能函数如下:

def extract_context_path(directory):
    """
    从application.properties或xxx.yml等Java项目配置文件中提取上下文路径
    """
    for dirPath, dirNames, fileNames in os.walk(directory):
        for filename in fileNames:
            if filename.endswith(".properties") or filename.endswith('.yml') or filename.endswith('.yaml'):
                file_path = os.path.join(dirPath, filename)
                with open(file_path, 'r', encoding='utf-8') as data:
                    data = data.readlines()
                    for line in data:
                        # 匹配 properties 文件
                        if line.startswith('server.servlet.context-path'):
                            context = line.split('=')[1].strip()
                            print(Fore.BLUE + "[*]Found context-path:" + context)
                            return context
                        # 匹配 yml 文件
                        elif line.find('context-path') > -1:
                            context = line.strip().split(':')[1].strip()
                            print(Fore.BLUE + "[*]Found context-path:" + context)
                            return context
                        else:
                            continue
    return None

最终扫描效果如下所示:

符合预期:

对若依项目的识别也是正确的:

总结

最后附上代码开源地址:https://github/Tr0e/RouteScanner。

本文实现了对 Java SpringBoot 项目一键自动化识别、统计路由信息,并生成可视化的统计表格,此类项目在 Github 上当前基本找不到开源参考代码仓,也算是为开源社区做点贡献了。当然了,初版代码因为当前的实验数据并不是很多,后期在其它 Java 源代码项目中也可能出现不适配的情况,后续有时间的话会持续优化、完善,欢迎提交 issues。

本文标签: 路由脚本项目信息SpringBoot