摘要由 AI 智能生成
记一次若依项目数据安全应急演练场景开发
今年的应急演练场景开始往数据安全方向走了,这次我直接拿RuoYi-Vue前后端分离项目开改,基于源码从零开始爆改,做了一套名为 "公共数据资产管理系统" 的演练环境,背景设定为 交通专网态势感知平台。
这次改造的内容,核心就是围绕"(权限管控失效)---(大规模敏感数据泄露)---(权限持久化失控)---(安全审计失效)---(破坏完整性)几个维度一起做设计。
整条链路在演练里的设计脚本大概是这样:
- 登录页弱口令尝试失败
- 通过注册入口注册普通账号
- 在"数据自查"功能附近形成越权突破口
- 获取超管密码信息后登录
- 在"人员登记"页面导出大批量敏感台账
- 借助高危功能链路继续扩大影响面
- 最终制造数据库异常,触发应急响应
后台更改
拿若依做演练,第一件事其实不是造漏洞,而是先把默认后台那种"系统管理后台"的味道压下去。
所以我第一步做的是把页面语义先立起来,围绕"公共数据资产管理系统"这个壳子补了一批新的菜单和业务入口,比如:
- 数据自查
- 人员登记
- 数据资产总览
- 数据库维护终端
- 应急任务编排
- 态势上报
人员登记菜单
这次改造里,工作量最大的一块其实是"人员登记台账"页面。这个页面要承担的作用很明确,就是在超管视角下形成一个足够有冲击力的数据资产页面,让"批量导出敏感信息"这件事在演练里成立。
我没有直接接数据库真实数据,而是走了一个更适合演练的做法:前端批量生成模拟数据。
页面表头我补了这些字段
<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
若依原生的计划任务模块有一层调用安全校验,包括 rmi、ldap、http(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));
}修改定时任务那里也是同样的处理方式,把这套拦截注释掉,保留前端操作入口。