数据安全应急演练场景开发思路--开发


摘要由 AI 智能生成

记一次若依项目数据安全应急演练场景开发

今年的应急演练场景开始往数据安全方向走了,这次我直接拿RuoYi-Vue前后端分离项目开改,基于源码从零开始爆改,做了一套名为 "公共数据资产管理系统" 的演练环境,背景设定为 交通专网态势感知平台

这次改造的内容,核心就是围绕"(权限管控失效)---(大规模敏感数据泄露)---(权限持久化失控)---(安全审计失效)---(破坏完整性)几个维度一起做设计。

整条链路在演练里的设计脚本大概是这样:

  1. 登录页弱口令尝试失败
  2. 通过注册入口注册普通账号
  3. 在"数据自查"功能附近形成越权突破口
  4. 获取超管密码信息后登录
  5. 在"人员登记"页面导出大批量敏感台账
  6. 借助高危功能链路继续扩大影响面
  7. 最终制造数据库异常,触发应急响应

后台更改

拿若依做演练,第一件事其实不是造漏洞,而是先把默认后台那种"系统管理后台"的味道压下去。

所以我第一步做的是把页面语义先立起来,围绕"公共数据资产管理系统"这个壳子补了一批新的菜单和业务入口,比如:

  • 数据自查
  • 人员登记
  • 数据资产总览
  • 数据库维护终端
  • 应急任务编排
  • 态势上报

人员登记菜单

这次改造里,工作量最大的一块其实是"人员登记台账"页面。这个页面要承担的作用很明确,就是在超管视角下形成一个足够有冲击力的数据资产页面,让"批量导出敏感信息"这件事在演练里成立。

我没有直接接数据库真实数据,而是走了一个更适合演练的做法:前端批量生成模拟数据

页面表头我补了这些字段

<el-table :data="tableData" border stripe style="width: 100%" v-loading="loading">
  <el-table-column prop="name" label="姓名" width="120"></el-table-column>
  <el-table-column prop="unit" label="所属单位" width="160"></el-table-column>
  <el-table-column prop="phone" label="手机号码" width="130"></el-table-column>
  <el-table-column prop="idCard" label="身份证号" width="180"></el-table-column>
  <el-table-column prop="address" label="家庭住址" min-width="200" show-overflow-tooltip></el-table-column>
  <el-table-column prop="createTime" label="入库时间" width="160"></el-table-column>
</el-table>

造数逻辑核心代码

generateMockData() {
  this.loading = true;

  const tzDistricts = ['海陵区', '高港区', '姜堰区', '泰兴市', '靖江市', '兴化市'];
  const subDepts = ['交通运输局', '公路事业发展中心', '综合行政执法局', '公安局交警大队'];
  const companies = ['交投集团', '公共交通有限公司', '顺丰速运分公司', '京东物流营业部'];
  const roads = ['迎春东路', '凤凰东路', '济川东路', '海陵南路', '鼓楼南路', '青年南路'];

  const tzPrefixes = ['321202', '321203', '321281', '321282', '321283', '321284'];
  const tzAddresses = ['江苏省泰州市海陵区', '江苏省泰州市高港区', '江苏省泰州市姜堰区', '江苏省泰兴市'];

  const data = [];
  const totalCount = 42782;

  for (let i = 0; i < totalCount; i++) {
    const prefix = tzPrefixes[Math.floor(Math.random() * tzPrefixes.length)];
    const addrPrefix = tzAddresses[Math.floor(Math.random() * tzAddresses.length)];

    const birthYear = 1960 + Math.floor(Math.random() * 46);
    const birthMonth = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
    const birthDay = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
    const randomCode = String(Math.floor(Math.random() * 10000)).padStart(4, '0');

    const idCard = `${prefix}${birthYear}${birthMonth}${birthDay}${randomCode}`;
    const address =
      addrPrefix +
      roads[Math.floor(Math.random() * roads.length)] +
      (Math.floor(Math.random() * 500) + 1) + '号' +
      (Math.floor(Math.random() * 20) + 1) + '栋' +
      (Math.floor(Math.random() * 6) + 1) + '0' +
      (Math.floor(Math.random() * 4) + 1) + '室';

    data.push({
      id: i + 1,
      name,
      unit,
      phone,
      idCard,
      address,
      createTime
    });
  }

  this.allData = data;
  this.handleQuery();
  this.loading = false;
}

导出功能

handleExport() {
  this.$confirm('确认要导出所有人员登记数据吗?', '警告', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    let csvContent = "序号,姓名,所属单位,手机号码,身份证号,家庭住址,入库时间\n";

    this.filteredData.forEach((row, index) => {
      const rowData = [
        index + 1,
        row.name,
        row.unit,
        row.phone,
        `"\t${row.idCard}"`,
        row.address,
        row.createTime
      ];
      csvContent += rowData.join(",") + "\n";
    });

    const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
    const link = document.createElement("a");
    const url = URL.createObjectURL(blob);
    link.setAttribute("href", url);
    link.setAttribute("download", "人员登记台账.csv");
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  });
}

数据自查菜单

如果只是做一个人员台账页面,场景还是太平。真正让整个攻击链转起来的,其实是"数据自查"这块。

我的思路不是单独做一个"漏洞利用"页面,而是做一个外观看起来非常正常的个人档案页。

前端页面核心展示

<el-descriptions title="我的档案信息" :column="2" border v-loading="loading">
  <el-descriptions-item label="用户账号">{{ resultData.userName || '未知' }}</el-descriptions-item>
  <el-descriptions-item label="用户昵称">{{ resultData.nickName || '未知' }}</el-descriptions-item>
  <el-descriptions-item label="手机号码">{{ resultData.phonenumber || '未绑定' }}</el-descriptions-item>
  <el-descriptions-item label="身份证号">
    <span>{{ maskIdCard(resultData.idCard) }}</span>
  </el-descriptions-item>
  <el-descriptions-item label="用户邮箱">{{ resultData.email || '未绑定' }}</el-descriptions-item>
  <el-descriptions-item label="登录密码">
    <span class="mask-password">********</span>
  </el-descriptions-item>
</el-descriptions>

前端自动查询逻辑

created() {
  this.fetchMyData();
},
methods: {
  fetchMyData() {
    const userName = this.$store.state.user.name;
    if (!userName) {
      this.$modal.msgError("无法获取当前登录账号");
      return;
    }
    this.loading = true;
    inspectUser(userName).then(res => {
      this.resultData = res.data || res;
      this.loading = false;
    }).catch(() => {
      this.loading = false;
    });
  },
  maskIdCard(idCard) {
    if (!idCard) return '未绑定';
    if (idCard.length === 18) {
      return idCard.substring(0, 10) + '****' + idCard.substring(14);
    }
    return idCard;
  }
}

前端调用的 API 方法

// 设计未授权访问接口
export function inspectUser(userName) {
  return request({
    url: '/system/***/***',
    method: 'post',
    data: { userName }
  })
}

数据自查接口

这个接口是整个越权演练的关键点。后端我新增了一个接口,专门接收前端传入的用户名参数来查询用户档案信息。

/**
 * 【漏洞演练】未授权访问与越权漏洞
 * 缺少 @PreAuthorize 注解,且直接返回包含敏感字段的 User 对象
 */
@Anonymous
@PostMapping("/inspect")
public AjaxResult inspect(@RequestBody SysUser param)
{
    SysUser user = userService.selectUserByUserName(param.getUserName());
    if (user != null) {
        java.util.Map<String, Object> result = new java.util.HashMap<>();
        result.put("userName", user.getUserName());
        result.put("nickName", user.getNickName());
        result.put("email", user.getEmail());
        // 强制暴露密码哈希(绕过实体类 @JsonIgnore 限制)
        result.put("password", user.getPassword());
        result.put("createTime", user.getCreateTime());

        // 演练逻辑:仅当查询的账号是 admin 时,才模拟返回高价值的手机号和身份证号
        // 其他账号(包含刚注册的普通用户)一律返回空
        if ("admin".equals(user.getUserName())) {
            String idCard = String.format("321202%d%04d", 19700000 + new java.util.Random().nextInt(30000), new java.util.Random().nextInt(10000));
            result.put("idCard", idCard);
            result.put("phonenumber", "13" + (new java.util.Random().nextInt(900000000) + 100000000));
        } else {
            result.put("idCard", "");
            result.put("phonenumber", "");
        }
        return AjaxResult.success(result);
    }
    return AjaxResult.error("用户不存在");
}

数据库操作终端

这套页面用来模拟高权限后台里的数据库操作能力。前端允许直接输入 SQL 语句执行。

前端 SQL 执行页面核心代码

handleExecute() {
  if (!this.sqlQuery) {
    this.$modal.msgError("请输入 SQL 语句");
    return;
  }
  this.loading = true;
  this.resultType = "";
  this.execTableData = [];
  this.execMsg = "";

  executeSql(this.sqlQuery).then(res => {
    this.loading = false;
    if (Array.isArray(res.data)) {
      this.resultType = "list";
      this.execTableData = res.data;
    } else {
      this.resultType = "msg";
      this.execMsg = res.data || res.msg;
      this.fetchTables();
    }
  }).catch(() => {
    this.loading = false;
  });
}

前端 SQL 调用的 API

import request from '@/utils/request'

// 执行 SQL
export function executeSql(sql) {
  return request({
    url: '/system/sql/execute',
    method: 'post',
    data: { sql }
  })
}

// 获取数据库表信息
export function getTables() {
  return request({
    url: '/system/sql/tables',
    method: 'get'
  })
}

后端 SQL 执行控制器

后端我新增了一个 SysSqlController,直接通过 JdbcTemplate 执行前端传入的 SQL。

package com.ruoyi.web.controller.system;

import com.ruoyi.common.core.domain.AjaxResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
 * SQL 执行终端
 */
@RestController
@RequestMapping("/system/sql")
public class SysSqlController {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @PreAuthorize("@ss.hasRole('admin')")
    @PostMapping("/execute")
    public AjaxResult executeSql(@RequestBody Map<String, String> param) {
        String sql = param.get("sql");
        if (sql == null || sql.trim().isEmpty()) {
            return AjaxResult.error("SQL 不能为空");
        }
        try {
            String lowerSql = sql.trim().toLowerCase();
            // 如果是查询语句,返回列表
            if (lowerSql.startsWith("select") || lowerSql.startsWith("show") || lowerSql.startsWith("desc")) {
                List<Map<String, Object>> result = jdbcTemplate.queryForList(sql);
                return AjaxResult.success(result);
            } else {
                // 如果是 DML 语句,执行并返回受影响行数
                int rows = jdbcTemplate.update(sql);
                return AjaxResult.success("执行成功,影响行数:" + rows);
            }
        } catch (Exception e) {
            return AjaxResult.error("SQL 执行失败:" + e.getMessage());
        }
    }

    @PreAuthorize("@ss.hasRole('admin')")
    @GetMapping("/tables")
    public AjaxResult getTables() {
        try {
            // 查询 MySQL 当前数据库中的所有表信息
            String sql = "SELECT TABLE_NAME as tableName, ENGINE as engine, TABLE_ROWS as tableRows, " +
                         "CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 2), ' MB') as dataSize, " +
                         "CREATE_TIME as createTime, TABLE_COMMENT as tableComment " +
                         "FROM information_schema.tables " +
                         "WHERE table_schema = (SELECT DATABASE())";
            List<Map<String, Object>> result = jdbcTemplate.queryForList(sql);
            return AjaxResult.success(result);
        } catch (Exception e) {
            return AjaxResult.error("获取表结构失败:" + e.getMessage());
        }
    }
}

定时任务:获取shell

若依原生的计划任务模块有一层调用安全校验,包括 rmildaphttp(s) 和白名单拦截。为了让演练里的主机控制链路能走通,我把这层校验注释掉了。

@PostMapping
public AjaxResult add(@RequestBody SysJob job) throws SchedulerException, TaskException
{
    if (!CronUtils.isValid(job.getCronExpression()))
    {
        return error("新增任务'" + job.getJobName() + "'失败,Cron表达式不正确");
    }
    // 演练需求:为了允许定时任务反弹 shell,移除所有的 RCE 拦截白名单与违规字符串校验
    /*
    else if (StringUtils.containsIgnoreCase(job.getInvokeTarget(), Constants.LOOKUP_RMI))
    {
        return error("新增任务'" + job.getJobName() + "'失败,目标字符串不允许'rmi'调用");
    }
    else if (StringUtils.containsAnyIgnoreCase(job.getInvokeTarget(), new String[] { Constants.LOOKUP_LDAP, Constants.LOOKUP_LDAPS }))
    {
        return error("新增任务'" + job.getJobName() + "'失败,目标字符串不允许'ldap(s)'调用");
    }
    else if (StringUtils.containsAnyIgnoreCase(job.getInvokeTarget(), new String[] { Constants.HTTP, Constants.HTTPS }))
    {
        return error("新增任务'" + job.getJobName() + "'失败,目标字符串不允许'http(s)'调用");
    }
    else if (StringUtils.containsAnyIgnoreCase(job.getInvokeTarget(), Constants.JOB_ERROR_STR))
    {
        return error("新增任务'" + job.getJobName() + "'失败,目标字符串存在违规");
    }
    else if (!ScheduleUtils.whiteList(job.getInvokeTarget()))
    {
        return error("新增任务'" + job.getJobName() + "'失败,目标字符串不在白名单内");
    }
    */
    job.setCreateBy(getUsername());
    return toAjax(jobService.insertJob(job));
}

修改定时任务那里也是同样的处理方式,把这套拦截注释掉,保留前端操作入口。

评论区
头像
文章目录