fix(sudo): block all fw_setenv options, not only -s/--script - #208
Conversation
|
FYI: @mlilien |
The wrapper compared the key and value against '-s', '--script' and their '=' forms. getopt accepts attached values and abbreviations, so a value like '-sFILE' or '--scr=FILE' reached fw_setenv as script mode, and '-cFILE' as an attacker-chosen config, both writing files as root. The sudoers rules pass the value unfiltered for the bootargs and factory-reset keys, so a twin-supplied value could exploit this. Pass '--' instead: key and value are always data, which covers every option rather than a list of known ones. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
361dacc to
1d6b663
Compare
There was a problem hiding this comment.
The change itself is right and I would take it. Two things need a fix before merge, both outside the diff.
1. The Reason section overstates the exposure
Those values come from the twin, so a crafted value could write files as root
The sudoers part is correct, the twin part is not, for all three keys:
factory-reset: the value isserde_json::to_string(&cmd)?(src/twin/factory_reset.rs:259). It always starts with{, so getopt can never read it as an option. Thepreservetopics are validated againstfactory_reset_keys()before that.omnect_extra_bootargs/omnect_validate_extra_bootargs: the value ismerge_bootargs()over/boot/omnect_extra_bootargs_omnectand/boot/omnect_extra_bootargs_custom(src/twin/firmware_update/mod.rs:490-497,src/twin/firmware_update/common.rs:67-79). Those are files on the boot partition from the update image, not twin properties.
So there is no live twin to root-write path today. The change is still worth doing as defense in depth, but please reword the section so it does not read as an exploitable escalation. Related: whoever can write /boot/omnect_extra_bootargs_custom already controls the kernel command line, which is worse than fw_setenv -c.
2. sudo/omnect-device-service-uboot:1-2 is now wrong
# note: use /usr/bin/fw_setenv_no_script.sh wrapper instead of fw_setenv for setting.
# it checks if the value string includes script parameters
The wrapper no longer checks the value. The PR says no sudoers rule needs a change, which is true for the rules, but this comment does.
3. sudo/fw_setenv_no_script.sh:9 (outside the diff, so no inline comment)
usage() still prints Script-file mode is not permitted., but it is now only reached on a wrong argument count. Before, script mode had its own error message. Drop the line, or keep only the Usage: line.
Verification I ran
I reproduced the getopt table with a C program using the same optstring and long options under glibc getopt_long - all three rows match. I also ran the wrapper against a stub fw_setenv: two arguments give -- KEY VALUE, zero, one or three or more give usage and exit 1. cargo fmt -- --check and cargo audit pass.
Not verified: libubootenv 0.3.5, src/fw_printenv.c:76 and the optstring itself - I only checked that this optstring behaves as claimed, not that fw_setenv uses it. cargo clippy and cargo test did not run here (azure-iot-sdk-dev.pc missing); no Rust changed, so low risk.
Minor, no change needed: the file name and FW_SETENV_NO_SCRIPT_BIN now describe a subset of what the wrapper does. Keeping the name to avoid a recipe change is fine, just noting the drift is permanent.
Signed-off-by: Joerg Zeidler joerg.zeidler@conplement.de
The wrapper's guarantee is that key and value reach fw_setenv as data. Run it against a stub that dumps its arguments and assert '--' plus the two positionals, so dropping '--' later fails the test instead of silently re-opening option injection. Also cover the argument count check. libubootenv's own parsing stays out of scope: only a real fw_setenv can show it, which needs a device test. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The wrapper no longer inspects the value for script parameters, it ends option parsing. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The '--' comment repeated the header and described what a flag blocklist would not do. Keep only the reason '--' is used instead of a filter: getopt takes attached values and abbreviations. Comment the argument count check instead, which is the load-bearing one: sudo matches command line arguments as one concatenated string, so a wildcard rule also matches extra arguments. Drop the script-file line from usage(), which is now only reached on a wrong argument count. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
|
Checked all three items, all correct. 1. Reason section — rewordedVerified both claims:
The section now says defense in depth and lists both reasons. I kept the sudoers part and added what is actually load-bearing: sudo matches command line arguments as one concatenated string ( The same wrong claim is in 2.
|
There was a problem hiding this comment.
All three items are addressed, and the reworded Reason section matches the code - I re-checked both claims.
Verified this round
- Replayed every case from
tests/fw_setenv_wrapper.rsagainst the current wrapper with a stubfw_setenv: the four option-like values, the option-like key and all four wrong argument counts behave as asserted. man 5 sudoers, "Wildcards in command arguments": "Command line arguments are matched as a single, concatenated string." The new comment on line 13 states this correctly.tempfile = "3.27"is already in[dev-dependencies]andsrc/lib.rsexists, so the integration test links.- The relative path in
WRAPPER_SRCmatches how the existing tests reachtestfiles/, so no change needed there. cargo fmt -- --checkpasses.cargo clippyandcargo teststill cannot run on my side (azure-iot-sdk-dev.pcmissing) - taking your run for those.
One item left, plus two inline
project-context.md does not mention the new tests/ directory. Section 3 lists testfiles/ and src/twin/mod_test.rs, and until now every test lived in a #[cfg(test)] mod inside the file under test. This PR opens a second location for tests that have no Rust module to live in; please add a line so the next contributor finds it.
On the commit message of f35f3c1
No action needed. The repository allows squash merge only (allow_merge_commit and allow_rebase_merge are both off), so the PR body becomes the commit message on main and the old wording never lands there. Leaving the branch as is was the right call.
Signed-off-by: Joerg Zeidler joerg.zeidler@conplement.de
A value with spaces must stay one argument, and an empty value must still reach fw_setenv - dropping the quotes around it would turn the call into a variable delete. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
JoergZeidler
left a comment
There was a problem hiding this comment.
Both suggestions applied verbatim, both test cases in. I pulled the libubootenv sources this round, which also closes the item I had listed as unverified in my first review - details in the test thread.
One item to correct, two small ones inline
Note that root
CLAUDE.mdcarries a generated copy of the same section and is currently stale for an unrelated reason (it still shows the pre-splitsrc/twin/feature/mod.rsentry). That drift predates this PR, so I left it out of scope.
That is not what I see. CLAUDE.md is gitignored (.gitignore:10) and untracked, and diffing section 3 of my generated copy against project-context.md gives exactly one difference - the line this PR adds:
17a18
> - `tests/` — integration tests for things with no Rust module to live in, …
Both files carry the src/twin/feature/mod.rs entry identically, so there is no older drift. The drift is the one this PR creates, and it clears with a local setup-ai.sh run. Nothing to commit since the file is ignored, but the "predates this PR" reasoning does not hold - worth knowing before the same argument is used to skip a regeneration that does matter.
Verified this round
src/fw_printenv.c:77on master:char *options = "Vc:f:s:nhm:";- the optstring in the PR body is correct.- Replayed both new cases against the wrapper:
console=ttyS0 root=/dev/sda2arrives as one argument,""arrives as[]. cargo fmt -- --checkpasses.cargo clippyandcargo teststill cannot run on my side (azure-iot-sdk-dev.pcmissing) - taking your run for those.
Signed-off-by: Joerg Zeidler joerg.zeidler@conplement.de
Section 3 is a file list, so the tests/ entry only describes the directory now. The rule about where tests live sits in section 4 instead. Also drop the lone doc comment on the empty-value test and carry the fact in its name. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
|
Both inline items applied verbatim in On the
|
|
Correction to my previous comment: the evidence I gave for the I wrote that I confirmed it by stashing my changes and re-running on Re-checked properly on The conclusion holds, and the mechanism is now clear: That also means |
src/twin/mod.rs keeps its tests in mod_test.rs via #[path], so "inside the file under test" did not cover every unit test in the repo. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
JoergZeidler
left a comment
There was a problem hiding this comment.
The --all-targets mechanism does not hold up
The corrected evidence is better, but the explanation still points at the wrong cause. I could not run it in the repo (azure-iot-sdk-dev.pc missing here), so I built a crate that mirrors the structure: build = "src/build.rs", the same cfg-guarded compile_error!, lib plus bin, a tests/ target, an optional dependency behind the feature, edition 2024, toolchain 1.93.0.
$ cargo clippy --features grub --all-targets -v | grep -- --crate-name build_script_build
--cfg 'feature="default"'
--cfg 'feature="grub"'
Cargo does pass the feature cfgs when compiling the build script, including under clippy --all-targets, and the whole run with -D warnings passes. The error text only appears when the feature is left off. So "cargo does not set feature cfgs for build scripts" is not what is happening on this toolchain.
What the pasted output shows is only the last line:
error: could not compile `omnect-device-service` (build script) due to 1 previous error
That is also the line you get when --all-targets lints src/build.rs and -D warnings promotes a clippy warning to an error - src/build.rs:29-33 has three .unwrap() calls. One command tells the two apart:
cargo clippy --features bootloader_grub --all-targets # no -D warningsIf that passes, it was a lint, not the compile_error!. The line directly above the error: in your original run would settle it too.
This matters for the conclusion, not for the branch: "--all-targets was never usable, so no lint coverage was lost" only follows if the cause really is the build script guard.
project-context.md:73,76 - the lint commands do not cover tests/
This part of your finding is right, and I confirmed it in the same crate: with a warning in tests/it.rs, cargo clippy --features grub -- -D warnings passes and --tests fails. This PR adds the first tests/ target and already edits project-context.md, so the entry belongs here rather than in a follow-up.
Use the mock variant, not bootloader_grub --all-targets: the unit tests in src/** need mock, for example clear_mock() is gated #[cfg(all(not(any(feature = "bootloader_grub", feature = "bootloader_uboot")), test))] (src/bootloader_env/mod.rs:72-76) and does not exist otherwise.
- **Lint:** `cargo clippy --features bootloader_grub -- -D warnings` and `cargo clippy --features mock --all-targets -- -D warnings`
- **Pre-commit check:** `cargo fmt && cargo clippy --features bootloader_grub -- -D warnings && cargo clippy --features mock --all-targets -- -D warnings`cargo fmt -- --check passes.
Signed-off-by: Joerg Zeidler joerg.zeidler@conplement.de
The documented lint command does not see src/** test code or tests/. Adding the mock --all-targets run covers both. It has to be mock: the self dev-dependency in Cargo.toml pulls that feature into every command covering test targets, so pairing --all-targets with a bootloader feature hits the mutual-exclusion check in src/build.rs. Also name the inline test mod as the rule and src/twin/mod.rs as its one exception, instead of offering both as a free choice. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
|
You were right that my mechanism was wrong. It is not a promoted clippy lint either — it is feature unification. Not
|
#679) ## Summary Harden the u-boot bootloader_env.sh wrapper against `fw_setenv`/`fw_printenv` option injection, refactor the wrapper along the way, and pin omnect-device-service 0.45.1 which carries the same fix for its own wrapper `sudo/fw_setenv_no_script.sh`. - Pass `--` to `fw_setenv`/`fw_printenv` so key and value are always treated as data. This blocks script mode and every other option, e.g. an attacker-chosen config file, which a flag blocklist would miss (getopt accepts attached values like `-sFILE` and abbreviations like `--scr=FILE`). - Rename `set`/`unset`/`get`/`list` to `cmd_*` so nothing shadows the bash builtins. - Each `cmd_*` takes its own quoted arguments and checks its own argc. Top-level dispatcher looks the function up via `declare -F` instead of an unquoted string match. - `get` prints values with `printf '%s\n'` and strips the `key=` prefix with a quoted pattern, so values with a leading `-n`/`-e` and keys with glob metacharacters do not misbehave. - Bump omnect-device-service to 0.45.1. Recipe regenerated with cargo-bitbake; crate set unchanged. ## Reason The old blocklist covered `-s`, `--script`, `-s=*` and `--script=*`. `fw_setenv` parses arguments with `getopt_long` and the optstring `Vc:f:s:nhm:` (libubootenv, `src/fw_printenv.c`), which accepts attached values (`-sFILE`) and abbreviations (`--scr=FILE`), so those forms passed the filter and reached `fw_setenv` as options. GNU getopt also keeps scanning after the first positional argument, so the value position was enough to sneak in an option. `recipes-azure-iot/iot-hub-device-update/iot-hub-device-update/adu-bootloader-env` pins the key but passes the value unfiltered for `omnect_extra_bootargs` and `omnect_validate_extra_bootargs`, so the `adu` user could write files as root — via a script file (`-s`) or an attacker-chosen config (`-c`), the latter not covered by a `-s`/`--script` blocklist at all. The same defect existed in ods's own wrapper `sudo/fw_setenv_no_script.sh` (omnect/omnect-device-service#208). Both copies ship in the same image, so the ods pin belongs in this PR — otherwise the image would keep the unfixed wrapper. Supersedes #678. --------- Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Summary
Pass
--tofw_setenvso key and value are always treated as data, instead of comparing them against a list of known flags.Reason
The wrapper blocked
-s,--script,-s=*and--script=*.fw_setenv(libubootenv 0.3.5,src/fw_printenv.c:76) parses its arguments withgetopt_longand the optstringVc:f:s:nhm:, which accepts attached values (-sFILE) and abbreviations (--scr=FILE), so those forms passed the filter and reachedfw_setenvas options.-c(attacker-chosen config) was not covered by the blocklist at all. GNU getopt also keeps scanning after the first positional argument, so the value position is enough. With--, all three arrive as positional data instead.The sudoers rules (
sudo/omnect-device-service-uboot) pin the key but pass the value unfiltered forfactory-reset,omnect_extra_bootargsandomnect_validate_extra_bootargs. There is no live path from the twin into those values today, so this is defense in depth, not a fix for an exploitable escalation:factory-reset: the value isserde_json::to_string(&cmd)?(src/twin/factory_reset.rs:259) and always starts with{, so getopt cannot read it as an option. Thepreservetopics are validated againstfactory_reset_keys()first.omnect_extra_bootargs/omnect_validate_extra_bootargs: the value ismerge_bootargs()over/boot/omnect_extra_bootargs_omnectand/boot/omnect_extra_bootargs_custom(src/twin/firmware_update/mod.rs:490-497) — files from the update image, not twin properties.What the wrapper does have to hold is the argument count. sudo matches command line arguments as one concatenated string (
man 5 sudoers, "Wildcards in command arguments"), so a rule likefw_setenv_no_script.sh omnect_extra_bootargs *also matches extra arguments.[[ $# -ne 2 ]]rejects those;--covers what arrives as key or value.tests/fw_setenv_wrapper.rspins the argument contract against a stubfw_setenv, so dropping--later fails the test. Two cases are worth naming: a value with spaces stays one argument, which is the shape the sudo concatenation rule is about; and an empty value must still be forwarded, because without the quotes around$VALUEthe call would becomefw_setenv -- KEY, a variable delete that only the separateunset:rules are meant to do. libubootenv's own parsing stays out of scope — only a realfw_setenvcan show it, which needs a device test.This is the repo's first
tests/target, and the documented lint command does not reach it.project-context.mdnow listscargo clippy --features mock --all-targets -- -D warningsalongside the existing one, and records why it has to bemock: the self dev-dependency inCargo.tomlturns that feature on, so pairing--all-targetswith a bootloader feature gives the build script both features at once and trips its mutual-exclusion check. The same file gains an entry for thetests/directory and a Test location constraint, since until now every test lived in a#[cfg(test)] modnext to the code.The wrapper's own contract is unchanged: two arguments, script mode not permitted — it is now structurally impossible rather than filtered, so the file name still applies and no caller, sudoers rule or recipe needs a change.