VYPR
Unrated severityNVD Advisory· Published Jun 3, 2026

CVE-2026-46447

CVE-2026-46447

Description

OpenStack Ironic through 35.0.x allows Boot Script Injection.

Affected products

1

Patches

1
c6c91d649fc3

Ensure kernel_append_params are valid kernel parameters

https://github.com/openstack/ironicClif HouckMay 5, 2026via github-commit-search
7 files changed · +550 1
  • ironic/common/kernel_parameters.py+175 0 added
    @@ -0,0 +1,175 @@
    +#    Licensed under the Apache License, Version 2.0 (the "License"); you may
    +#    not use this file except in compliance with the License. You may obtain
    +#    a copy of the License at
    +#
    +#         http://www.apache.org/licenses/LICENSE-2.0
    +#
    +#    Unless required by applicable law or agreed to in writing, software
    +#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
    +#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
    +#    License for the specific language governing permissions and limitations
    +#    under the License.
    +
    +import lark
    +
    +from dataclasses import dataclass
    +
    +from ironic.common.exception import InvalidParameterValue
    +from ironic.common.i18n import _
    +
    +
    +def sanitize_kernel_command_line(command_line: str) -> str:
    +    """Applies filtering to a command line to sanitize it.
    +
    +    NOTE: This does not guarantee a correct or safe kernel command line,
    +    for stronger guarantees of correctness and safety use
    +    KernelCommandLine.parse().
    +
    +    :param command_line: A string containing a kernel command line or
    +        individual parameters.
    +    :returns: A filtered string which should be safer for use.
    +    """
    +    return ''.join(c for c in command_line if c not in {'\n', '\r', '\0'})
    +
    +
    +@dataclass(frozen=True)
    +class ParameterKey:
    +    key: str
    +
    +    def __str__(self):
    +        return self.key
    +
    +
    +@dataclass(frozen=True)
    +class ParameterValue:
    +    value: str
    +
    +    def __str__(self):
    +        if ' ' in self.value:
    +            return f"\"{self.value}\""
    +        return self.value
    +
    +
    +@dataclass(frozen=True)
    +class KernelParameter:
    +    key: ParameterKey
    +    value: ParameterValue
    +
    +    def __str__(self):
    +        if len(self.value.value) > 0:
    +            return f"{self.key.key}={self.value.value}"
    +        return self.key.key
    +
    +
    +@dataclass(frozen=True)
    +class KernelCommandLine:
    +    parameters: dict[str, list[KernelParameter]]
    +    init_args: str
    +
    +    def __str__(self):
    +        output = ' '.join(
    +            ' '.join(str(param) for param in param_list)
    +            for param_list in self.parameters.values())
    +        if len(self.init_args) > 0:
    +            output += " -- " + self.init_args
    +        return output
    +
    +    @classmethod
    +    def parse(cls, command_line: str):
    +        try:
    +            tree = KernelParameterParser.parse(command_line)
    +            return KernelParameterTransformer().transform(tree)
    +        except (lark.exceptions.LarkError,
    +                lark.exceptions.UnexpectedInput) as e:
    +            raise InvalidParameterValue(
    +                _('Kernel command line did not parse: "%s" -- %s') \
    +                % (command_line, str(e))) from None
    +
    +
    +# NOTE(clif): Some valid values (such as filenames) are not going to be
    +# representable given we're explicitly not allowing large swaths characters
    +# in parameter values. I believe this is reasonable. Most people do not
    +# purposefully put non-printable/control characters in kernel parameters for
    +# anything other than nefarious goals.
    +# NOTE(clif): bare_value and value_with_spaces permit a *large* range of
    +# printable ASCII characters because many are used as characters with special
    +# meaning in several kernel parameters.
    +# NOTE(clif): The only permitted white-space character in this grammar is a
    +# space.
    +KERNEL_PARAMETER_GRAMMAR = r"""
    +kernel_command_line:  parameter_list [init_suffix]
    +
    +parameter_list: parameter*(" "+ parameter)*
    +
    +parameter: key
    +         | key_value_pair
    +
    +key_value_pair: key"="value
    +
    +key: /[A-Za-z0-9_\-\.]+/
    +
    +value: bare_value
    +     | quoted_value
    +
    +quoted_value: "\"" value_with_spaces "\""
    +
    +bare_value: /[\!\#-\\.0-9:-\@A-Z\[-~]+/
    +
    +value_with_spaces: /[\!\#-\\.0-9:-\@A-Z\[-~ ]+/
    +
    +init_suffix: " "+ "--" " "+ init_arguments
    +
    +init_arguments: value_with_spaces
    +"""
    +
    +KERNEL_PARAMETER_GRAMMER_START_RULE = 'kernel_command_line'
    +
    +KernelParameterParser = lark.Lark(KERNEL_PARAMETER_GRAMMAR,
    +                                  start=KERNEL_PARAMETER_GRAMMER_START_RULE,
    +                                  strict=True)
    +
    +
    +class KernelParameterTransformer(lark.Transformer):
    +    def kernel_command_line(self, items):
    +        return KernelCommandLine(items[0], items[1] or '')
    +
    +    def parameter_list(self, items):
    +        parameters = {}
    +        for item in items:
    +            if item.key.key in parameters.keys():
    +                parameters[item.key.key].append(item)
    +            else:
    +                parameters[item.key.key] = [item]
    +        return parameters
    +
    +    def parameter(self, items):
    +        if isinstance(items[0], ParameterKey):
    +            return KernelParameter(items[0], ParameterValue(""))
    +        return items[0]
    +
    +    def key_value_pair(self, items):
    +        key = items[0]
    +        value = items[1]
    +        return KernelParameter(key, value)
    +
    +    def key(self, items):
    +        return ParameterKey(items[0].value)
    +
    +    def value(self, items):
    +        return ParameterValue(items[0])
    +
    +    def quoted_value(self, items):
    +        # Strip " characters from literal.
    +        return items[0].value[1:-1]
    +
    +    def bare_value(self, items):
    +        return items[0].value
    +
    +    def value_with_spaces(self, items):
    +        return items[0].value
    +
    +    def init_suffix(self, items):
    +        return items[0]
    +
    +    def init_arguments(self, items):
    +        return items[0]
    
  • ironic/common/pxe_utils.py+2 0 modified
    @@ -962,6 +962,8 @@ def build_pxe_config_options(task, pxe_info, service=False,
                                as kernel command-line arguments.
         :returns: A dictionary of pxe options to be used in the pxe bootfile
             template.
    +
    +    :raises: InvalidParameterValue via get_kernel_append_params
         """
         node = task.node
         mode = deploy_utils.rescue_or_deploy_mode(node)
    
  • ironic/conf/conductor.py+13 0 modified
    @@ -764,6 +764,19 @@
                            'adds one additional BMC query per node during each '
                            'power sync cycle, which may impact performance in '
                            'large deployments.')),
    +
    +    cfg.BoolOpt('disable_kernel_parameter_parsing',
    +                default=False,
    +                # Normally such an option would be mutable, but this is,
    +                # a security guard and operators should not expect to change
    +                # this option under normal circumstances.
    +                mutable=False,
    +                help=_('Disable parsing of kernel parameters. Kernel '
    +                       'parameter parsing allows Ironic to detect and prevent '
    +                       'malformed kernel parameters before they are passed to '
    +                       'nodes. Malformed kernel parameters can pose a '
    +                       'security risk therefore it is not recommended to '
    +                       'disable this option unless absolutely necessary.')),
     ]
     
     
    
  • ironic/drivers/utils.py+21 1 modified
    @@ -24,6 +24,7 @@
     
     from ironic.common import exception
     from ironic.common.i18n import _
    +from ironic.common import kernel_parameters
     from ironic.common import states
     from ironic.common import swift
     from ironic.conductor import utils
    @@ -428,11 +429,30 @@ def get_kernel_append_params(node, default):
     
         :param node: Node object.
         :param default: Default value.
    +
    +    :raises: InvalidParameterValue if kernel_append_params is an invalid
    +             string to append to a kernel command line.
         """
         for location in ('instance_info', 'driver_info'):
             result = getattr(node, location).get('kernel_append_params')
             if result is not None:
    -            return result.replace('%default%', default or '')
    +            result = result.replace('%default%', default or '')
    +
    +            if not CONF.conductor.disable_kernel_parameter_parsing:
    +                # NOTE(clif) Attempt to parse the append params. Failure to
    +                # parse indicates malformed kernel parameters and should be
    +                # rejected. parse() will raise if parsing fails.
    +                try:
    +                    kernel_parameters.KernelCommandLine.parse(result)
    +                except exception.InvalidParameterValue:
    +                    raise exception.InvalidParameterValue(
    +                        _('node\'s %s[\'kernel_append_params\'] contains '
    +                          'malformed kernel command line') % location)
    +
    +            # NOTE(clif) Always run basic sanitization on kernel_append_params
    +            result = kernel_parameters.sanitize_kernel_command_line(result)
    +
    +            return result
     
         return default
     
    
  • ironic/tests/unit/common/test_kernel_parameters.py+229 0 added
    @@ -0,0 +1,229 @@
    +#    Licensed under the Apache License, Version 2.0 (the "License"); you may
    +#    not use this file except in compliance with the License. You may obtain
    +#    a copy of the License at
    +#
    +#         http://www.apache.org/licenses/LICENSE-2.0
    +#
    +#    Unless required by applicable law or agreed to in writing, software
    +#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
    +#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
    +#    License for the specific language governing permissions and limitations
    +#    under the License.
    +
    +from ironic.common.exception import InvalidParameterValue
    +import ironic.common.kernel_parameters as kp
    +from ironic.tests import base
    +
    +from ddt import data
    +from ddt import ddt
    +from ddt import unpack
    +import lark
    +
    +
    +def annotate(name, *args):
    +    class AnnotatedList(list):
    +        pass
    +
    +    al = AnnotatedList([*args])
    +    al.__name__ = name
    +    return al
    +
    +
    +def generate_invalid_characters_to_test():
    +    invalid_characters_to_test = [
    +        chr(c) for c in range(0, 32)
    +    ]
    +    invalid_characters_to_test.extend([
    +        "\n",
    +        "\r",
    +        chr(127),
    +    ])
    +    invalid_characters_to_test.extend([
    +        chr(c) for c in range(128, 160)
    +    ])
    +    return invalid_characters_to_test
    +
    +
    +INVALID_CHARACTERS = generate_invalid_characters_to_test()
    +
    +
    +@ddt
    +class KernelParametersTestCase(base.TestCase):
    +    @data(
    +        annotate(
    +            "Filtering newlines",
    +            "quiet\n",
    +            "quiet"
    +        ),
    +        annotate(
    +            "Filtering carraige returns",
    +            "qu\riet",
    +            "quiet"
    +        ),
    +        annotate(
    +            "Filtering NULL",
    +            "\0quiet",
    +            "quiet"
    +        ),
    +        annotate(
    +            "Nothing needs changing - a real valid kernel cmdline",
    +            ("BOOT_IMAGE=(hd5,gpt2)/vmlinuz-6.19.9-200.fc43.x86_64 "
    +             "root=UUID=217c8a40-4956-11f1-9c98-d8bbc1c85452 ro "
    +             "rootflags=subvol=root "
    +             "rd.luks.uuid=luks-3a516752-4956-11f1-aa13-d8bbc1c85452 "
    +             "rhgb quiet rd.driver.blacklist=nouveau,nova_core "
    +             "modprobe.blacklist=nouveau,nova_core"),
    +            ("BOOT_IMAGE=(hd5,gpt2)/vmlinuz-6.19.9-200.fc43.x86_64 "
    +             "root=UUID=217c8a40-4956-11f1-9c98-d8bbc1c85452 ro "
    +             "rootflags=subvol=root "
    +             "rd.luks.uuid=luks-3a516752-4956-11f1-aa13-d8bbc1c85452 "
    +             "rhgb quiet rd.driver.blacklist=nouveau,nova_core "
    +             "modprobe.blacklist=nouveau,nova_core")
    +        ),
    +    )
    +    @unpack
    +    def test_sanitize_kernel_command_line(
    +            self, command_line: str, expected_result: str):
    +        self.assertEqual(
    +            expected_result,
    +            kp.sanitize_kernel_command_line(command_line))
    +
    +    def test_grammar_acceptable_to_lark(self):
    +        parser = lark.Lark(
    +            kp.KERNEL_PARAMETER_GRAMMAR,
    +            start=kp.KERNEL_PARAMETER_GRAMMER_START_RULE)
    +        self.assertIsNotNone(parser)
    +
    +    @data(
    +        annotate(
    +            "Single key=value pair",
    +            "BOOT_IMAGE=(hd5,gpt2)/vmlinuz-6.19.9-200.fc43.x86_64",
    +            kp.KernelCommandLine({
    +                'BOOT_IMAGE': [kp.KernelParameter(
    +                    kp.ParameterKey('BOOT_IMAGE'),
    +                    kp.ParameterValue(
    +                        '(hd5,gpt2)/vmlinuz-6.19.9-200.fc43.x86_64')
    +                )],
    +            }, "")
    +        ),
    +        annotate(
    +            "Single key",
    +            "quiet",
    +            kp.KernelCommandLine({
    +                'quiet': [kp.KernelParameter(
    +                    kp.ParameterKey('quiet'),
    +                    kp.ParameterValue(''),
    +                )],
    +            }, "")
    +        ),
    +        annotate(
    +            "Two parameters",
    +            "quiet BOOT_IMAGE=(hd5,gpt2)/vmlinuz-6.19.9-200.fc43.x86_64",
    +            kp.KernelCommandLine({
    +                'quiet': [kp.KernelParameter(
    +                    kp.ParameterKey('quiet'),
    +                    kp.ParameterValue(''),
    +                )],
    +                'BOOT_IMAGE': [kp.KernelParameter(
    +                    kp.ParameterKey('BOOT_IMAGE'),
    +                    kp.ParameterValue(
    +                        '(hd5,gpt2)/vmlinuz-6.19.9-200.fc43.x86_64')
    +                )],
    +            }, "")
    +        ),
    +        annotate(
    +            "A real linux kernel cmdline",
    +            ("BOOT_IMAGE=(hd5,gpt2)/vmlinuz-6.19.9-200.fc43.x86_64 "
    +             "root=UUID=217c8a40-4956-11f1-9c98-d8bbc1c85452 ro "
    +             "rootflags=subvol=root "
    +             "rd.luks.uuid=luks-3a516752-4956-11f1-aa13-d8bbc1c85452 "
    +             "rhgb quiet rd.driver.blacklist=nouveau,nova_core "
    +             "modprobe.blacklist=nouveau,nova_core"),
    +            kp.KernelCommandLine({
    +                'BOOT_IMAGE': [kp.KernelParameter(
    +                    kp.ParameterKey('BOOT_IMAGE'),
    +                    kp.ParameterValue(
    +                        '(hd5,gpt2)/vmlinuz-6.19.9-200.fc43.x86_64')
    +                )],
    +                'root': [kp.KernelParameter(
    +                    kp.ParameterKey('root'),
    +                    kp.ParameterValue(
    +                        'UUID=217c8a40-4956-11f1-9c98-d8bbc1c85452'),
    +                )],
    +                'ro': [kp.KernelParameter(
    +                    kp.ParameterKey('ro'),
    +                    kp.ParameterValue(''),
    +                )],
    +                'rootflags': [kp.KernelParameter(
    +                    kp.ParameterKey('rootflags'),
    +                    kp.ParameterValue('subvol=root'),
    +                )],
    +                'rd.luks.uuid': [kp.KernelParameter(
    +                    kp.ParameterKey('rd.luks.uuid'),
    +                    kp.ParameterValue(
    +                        'luks-3a516752-4956-11f1-aa13-d8bbc1c85452'),
    +                )],
    +                'rhgb': [kp.KernelParameter(
    +                    kp.ParameterKey('rhgb'),
    +                    kp.ParameterValue(''),
    +                )],
    +                'quiet': [kp.KernelParameter(
    +                    kp.ParameterKey('quiet'),
    +                    kp.ParameterValue(''),
    +                )],
    +                'rd.driver.blacklist': [kp.KernelParameter(
    +                    kp.ParameterKey('rd.driver.blacklist'),
    +                    kp.ParameterValue('nouveau,nova_core'),
    +                )],
    +                'modprobe.blacklist': [kp.KernelParameter(
    +                    kp.ParameterKey('modprobe.blacklist'),
    +                    kp.ParameterValue('nouveau,nova_core'),
    +                )],
    +            }, "")
    +        ),
    +        annotate(
    +            "Multiple parameters with the same key",
    +            "initrd=/initramfs-linux.img initrd=ramdisk",
    +            kp.KernelCommandLine({
    +                'initrd': [kp.KernelParameter(
    +                    kp.ParameterKey('initrd'),
    +                    kp.ParameterValue('/initramfs-linux.img')
    +                 ),
    +                 kp.KernelParameter(
    +                    kp.ParameterKey('initrd'),
    +                    kp.ParameterValue('ramdisk')
    +                 )]
    +            }, "")
    +        ),
    +        annotate(
    +            "init arguments",
    +            "quiet -- some init args",
    +            kp.KernelCommandLine({
    +                'quiet': [kp.KernelParameter(
    +                    kp.ParameterKey('quiet'),
    +                    kp.ParameterValue(''),
    +                )],
    +            }, "some init args")
    +        ),
    +    )
    +    @unpack
    +    def test_kernel_command_line_parsing(
    +            self, command_line: str, expected_result: kp.KernelCommandLine):
    +        result = kp.KernelCommandLine.parse(command_line)
    +        # Assert parsing the command line spits out the expected
    +        # object.
    +        self.assertEqual(expected_result, result)
    +        # Assert rendering the object back to a string matches the initial
    +        # command line string.
    +        self.assertEqual(command_line, str(result))
    +
    +    @data(
    +        *[annotate(
    +                f"character ordinal {ord(c)} shouldn't parse",
    +                f"ro{c}quiet",) for c in INVALID_CHARACTERS]
    +    )
    +    @unpack
    +    def test_invalid_kernel_command_lines_fail_to_parse(
    +            self, command_line: str):
    +        with self.assertRaises(InvalidParameterValue):
    +            kp.KernelCommandLine.parse(command_line)
    
  • ironic/tests/unit/drivers/test_utils.py+97 0 modified
    @@ -13,10 +13,14 @@
     #    License for the specific language governing permissions and limitations
     #    under the License.
     
    +from dataclasses import dataclass
     import datetime
     import os
     from unittest import mock
     
    +from ddt import data
    +from ddt import ddt
    +from ddt import unpack
     from oslo_config import cfg
     from oslo_utils import timeutils
     
    @@ -32,6 +36,15 @@
     from ironic.tests.unit.objects import utils as obj_utils
     
     
    +def annotate(name, *args):
    +    class AnnotatedList(list):
    +        pass
    +
    +    al = AnnotatedList([*args])
    +    al.__name__ = name
    +    return al
    +
    +
     class UtilsTestCase(db_base.DbTestCase):
     
         def setUp(self):
    @@ -234,6 +247,90 @@ def test_get_field_bootloader_by_arch(self):
             self.assertEqual('grubaa64.efi', result)
     
     
    +@ddt
    +class GetKernelAppendParamsTestCase(tests_base.TestCase):
    +    @dataclass(frozen=True)
    +    class FauxTestNode:
    +        instance_info: dict
    +        driver_info: dict
    +
    +    @data(
    +        annotate(
    +            "valid params in instance_info",
    +            FauxTestNode({'kernel_append_params': 'quiet ro'},
    +                         {}),
    +            '',
    +            'quiet ro',
    +            False,
    +            False
    +        ),
    +        annotate(
    +            "valid params in driver_info",
    +            FauxTestNode({},
    +                         {'kernel_append_params': 'quiet ro'}),
    +            '',
    +            'quiet ro',
    +            False,
    +            False
    +        ),
    +        annotate(
    +            "params in default",
    +            FauxTestNode({}, {}),
    +            'quiet ro',
    +            'quiet ro',
    +            False,
    +            False
    +        ),
    +        annotate(
    +            "invalid params in instance_info raises",
    +            FauxTestNode({'kernel_append_params': 'bad\nparams'}, {}),
    +            '',
    +            '',
    +            True,
    +            False
    +
    +        ),
    +        annotate(
    +            "invalid params in driver_info raises",
    +            FauxTestNode({}, {'kernel_append_params': 'bad\nparams'}),
    +            '',
    +            '',
    +            True,
    +            False
    +        ),
    +        annotate(
    +            "parsing disabled - but newline is filtered",
    +            FauxTestNode({}, {'kernel_append_params': 'quiet\n ro'}),
    +            '',
    +            'quiet ro',
    +            False,
    +            True,
    +        ),
    +    )
    +    @unpack
    +    def test_get_kernel_append_params(
    +            self,
    +            test_node: FauxTestNode,
    +            default: str,
    +            expected_result: str,
    +            should_raise: bool,
    +            disable_kernel_parameter_parsing: bool):
    +        cfg.CONF.set_override('disable_kernel_parameter_parsing',
    +                              disable_kernel_parameter_parsing,
    +                              'conductor')
    +        if should_raise:
    +            self.assertRaises(
    +                exception.InvalidParameterValue,
    +                driver_utils.get_kernel_append_params,
    +                test_node,
    +                default)
    +        else:
    +            self.assertEqual(
    +                expected_result,
    +                driver_utils.get_kernel_append_params(test_node,
    +                                                      default))
    +
    +
     class UtilsRamdiskLogsTestCase(tests_base.TestCase):
     
         def setUp(self):
    
  • releasenotes/notes/sanitize-kernel-append-params-8b2953a9d903d0f6.yaml+13 0 added
    @@ -0,0 +1,13 @@
    +---
    +security:
    +  - |
    +    Fixes a security issue where a malicious 'kernel_append_params' in a node's
    +    'instance_info' or 'driver_info' could cause an attacker to take control of
    +    a node's initial boot through boot script injection. The fix includes
    +    sanitization of 'kernel_append_params' to prevent such injection. Strict
    +    parsing of kernel parameters is now in place as well and enabled by
    +    default. If an operator needs to disable such strict parsing they may do
    +    so by setting the configuration option
    +    conductor.disable_kernel_parameter_parsing to 'True'. However, this is
    +    discouraged as it weakens the security posture of Ironic. This fix
    +    addresses CVE-2026-46447.
    

Vulnerability mechanics

No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.

References

3

News mentions

0

No linked articles in our index yet.