TL;DR
- What: In Eclipse hawkBit, the device API's artifact download ran its assignment check only when the request had no
Rangeheader. A device that addedRange: bytes=0-got the file anyway. The advisory lists 1.0.3 and prior as affected. - Impact: per the advisory, any authenticated device could download firmware artifacts assigned to other devices in the same tenant. It is not an authentication bypass, and it stays inside the tenant. Rated Moderate 4.3 (
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N). CWE-862 and CWE-284. - Fixed in 1.0.4, advisory GHSA-92r3-p8c2-3fpx, credited to me as reporter. CVE-2026-16454, assigned by the Eclipse CNA, lists me as finder.
Why you should care
hawkBit's README describes it as a domain-independent back end for rolling out software updates to constrained edge devices, controllers and gateways. An operator assigns a distribution set to a device, and that device downloads the artifacts in it.
The advisory states the boundary plainly: a device should be restricted to the firmware artifacts explicitly assigned to it. This bug removed that boundary for one kind of request. The credentials still had to be valid, and per the advisory the tenant limit held. What failed was the separation between devices inside one tenant.
A device also needs a module id and an artifact filename to ask for. The advisory and the CVE record both describe a related metadata endpoint: given a module id, it returned the artifact filenames, sizes and hashes, including for a module not assigned to the device.
The setup
DDI (Direct Device Integration) is the API devices speak. A device can authenticate with its own token, sent as Authorization: TargetToken <token>.
The per-device check is JpaControllerManagement.getActionForDownloadByTargetAndSoftwareModule(). It takes the device's active actions and looks for one whose distribution set contains the requested module. At tag 1.0.3, lines 365-374:
return actionRepository .findAll(ActionSpecifications.byTargetControllerIdAndActive(controllerId, true)) .stream() .filter(action -> !action.isCancelingOrCanceled()) .filter(action -> action.getDistributionSet().getModules() .stream() .anyMatch(module -> module.getId() == moduleId)) .map(Action.class::cast) .findFirst() .orElseThrow(() -> new SoftwareModuleNotAssignedToTargetException(moduleId, controllerId)); That exception extends EntityNotFoundException and comes back as an HTTP 404. The check works. The question is when it runs.
The bug
This is DdiRootController.downloadArtifact() at tag 1.0.3, in hawkbit-ddi/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java, lines 181-184:
final ActionStatus actionStatus = RequestResponseContextHolder.getHttpServletRequest().getHeader("Range") == null ? logDownload(RequestResponseContextHolder.getHttpServletRequest(), target, module.getId()) : null; // range request - could have too many - so doesn't check action, don't log action status, and don't publish events result = FileStreamingUtil.writeFileResponse(file, artifact.getFilename(), artifact.getCreatedAt(), ... logDownload() holds the only non-test call to the check in the 1.0.3 tree. It looks up the action, then writes an ActionStatus row, at lines 586-594:
private ActionStatus logDownload(final HttpServletRequest request, final Target target, final Long module) { final Action action = controllerManagement.getActionForDownloadByTargetAndSoftwareModule(target.getControllerId(), module); return controllerManagement.addInformationalActionStatus( ActionStatusCreate.builder() .actionId(action.getId()) .status(Status.DOWNLOAD) .messages(List.of(RepositoryConstants.SERVER_MESSAGE_PREFIX + "Target downloads " + request.getRequestURI())) .build()); } So with a Range header the ternary evaluates to null, nothing calls the check, and the next statement streams the file.
The lookup before it doesn't cover the gap. getSoftwareModule() is softwareModuleRepository.findById(id), and it takes no device argument.
The "aha"
The check was skipped on purpose and in plain sight, as a load problem. It was also the only thing tying a device to the firmware it asked for.
Until January 2025 the helper was called checkAndLogDownload(), and it ran on every download (tag 0.7.0, lines 192 and 598). Issue #1249 reported what that cost streaming installers. RAUC issues Range requests of about 128 KB, so a 150 MB bundle produced more than 1000 ActionStatus rows and hit the quota of 1000 per action.
PR #2261 dealt with that. Its title is about action statuses, but its body also lists too many action checks as a reason and says "this PR skip action existing check and skip logging". The same PR renamed the helper to logDownload(). PR #2262, merged 28 minutes later, added the ternary and the comment shown above. Going by the source at each tag, 0.8.0 (March 2025) is the first tag that carries the skip.
So nothing was hidden. The skip is in the PR text and in the code. But neither the PR nor the issue calls that lookup an authorization check. It was described by what it cost, and described that way, skipping it looks like a sensible fix for a real problem. The proof of concept below shows nothing else on that path stood in for it.
Proof of concept (benign)
Everything ran locally in Docker with synthetic data. No hosted or third-party system was involved. The target was hawkbit/hawkbit-update-server:1.0.3 on port 8585, default tenant DEFAULT, with target-token authentication enabled (authentication.targettoken.enabled=true; the update server's application.properties ships it turned off). deviceA and deviceB each had their own token. Software module 1 holds fw.bin, whose content is a short marker string, SECRET_FIRMWARE_FOR_DEVICE_A_ONLY_…, in a distribution set assigned to deviceA only.
The run recorded the path, the token, the status and the body, not a shell transcript, so read these commands as the form of the requests. Both authenticate as deviceB with deviceB's own token:
curl -i -H "Authorization: TargetToken $TOKEN_B" \ http://localhost:8585/DEFAULT/controller/v1/deviceB/softwaremodules/1/artifacts/fw.bin curl -i -H "Authorization: TargetToken $TOKEN_B" -H "Range: bytes=0-" \ http://localhost:8585/DEFAULT/controller/v1/deviceB/softwaremodules/1/artifacts/fw.bin What was recorded:
- deviceB, no
Range: 404,SoftwareModuleNotAssignedToTargetException. Correctly denied. - deviceB,
Range: bytes=0-: 206, bodySECRET_FIRMWARE_FOR_DEVICE_A_ONLY_…, the full artifact. - deviceA (assigned), no
Range: 200, the same firmware.
The only difference between the 404 and the 206 is the header.
Honest boundary: this is one artifact, two devices and one tenant. I did not test across tenants, so the tenant limit is the advisory's statement, not my result. The marker stands in for firmware, so this shows the boundary was crossed and nothing about what a real image contains.
The fix
Shipped in 1.0.4 (Release, 2026-06-26). This is DdiRootController.java at tag 1.0.4, lines 192-197, from commit 6cb34fd77f6a on the 1.0 branch and 83fee2ba98fb on master:
// check action assigned to device final Action action = Objects.requireNonNull( controllerManagement.getActionForDownloadByTargetAndSoftwareModule(target.getControllerId(), module.getId())); final ActionStatus actionStatus = getHttpServletRequest().getHeader("Range") == null ? logDownload(action) : null; // range request - could have too many - so doesn't check action, don't log action status, and don't publish events The check now runs before the Range ternary, and logDownload() only receives the action. Range requests still skip the status row, so the action-status quota problem from issue #1249 stays fixed. The comment on the : null line was left as it was, and at 1.0.4 it is out of date.
Two related changes shipped with it. The .MD5SUM path, which was never gated on Range, now makes the same explicit check. And the artifact metadata listing, getSoftwareModulesArtifacts(), gained an assignment check (914b8b406bd7 on the 1.0 branch, 9dc7d2120a05 on master), with a fallback so a device can still list modules in its installed action.
The maintainer asked me to confirm the fix, so on 18 June I built master at commit 6e510bdc and re-ran the PoC locally, again with synthetic data:
- deviceB,
Range: bytes=0-: 404, no firmware bytes. This was the 206 on 1.0.3. - deviceB, no
Range: 404, unchanged. - deviceA, no
Range: 200 with the firmware, unchanged. - deviceA,
Range: bytes=0-: 206 with the firmware. I added this one in the re-run; it was not run on 1.0.3. - deviceB, metadata listing for module 1: 200 with the filename, size and hashes. Still open on that build.
The two deviceA results show the new 404 is the check firing, not a broken route. I flagged the last result in my reply, and the listing's own check landed on master the same day.
I did not re-run on the 1.0.4 build. downloadArtifact() at 1.0.4 is the same code as at that commit. The metadata check is in the 1.0.4 source at lines 132-147; I have not run it.
A note on severity
The advisory rates this Moderate, CVSS 4.3, with confidentiality Low. Its stated reasons are that access stays inside the authenticated tenant and that production firmware is by design not expected to hold highly sensitive data. I filed it at 6.5 with confidentiality High. That is the only difference between the two vectors, and 4.3 is the published rating.
Takeaways
- When a performance change removes a lookup, ask whether that lookup was the authorization decision. PR #2261 named the action check and skipped it for load reasons. In hawkBit that one lookup was the whole per-device check on the download path.
- A good name did not save it. The helper was
checkAndLogDownload(), with the check in its name, and the PR that skipped the check also renamed it. A separately named assertion could have been skipped by the same decision. -
getSoftwareModule()answered whether the module exists, not whether this device may have it. Passing that lookup told the download path nothing about the device.
Disclosure timeline
- 2026-06-15: found during a source review of
DdiRootController. - 2026-06-16: reproduced locally on the
hawkbit-update-server:1.0.3image and reported privately through GitHub's private vulnerability reporting. - 2026-06-17: fix landed on master.
- 2026-06-18: the maintainer asked me to confirm the fix. I built master at
6e510bdcand re-ran locally. The Range bypass did not reproduce. The same run found the metadata listing still open on that build. I flagged that in my reply, and the check for it landed on master that day. - 2026-06-25: 1.0.4 tagged.
- 2026-06-26: 1.0.4 Release published, then advisory GHSA-92r3-p8c2-3fpx published by the maintainer, crediting me as reporter.
- 2026-07-21: CVE-2026-16454 published by the Eclipse CNA: affected through 1.0.3, unaffected 1.0.4, with me listed as finder.
Ten days from report to published advisory, with the maintainer asking for the fix to be checked first. That is a well-run disclosure process.
Credit / CTA
If you run Eclipse hawkBit, upgrade to 1.0.4 or later. From 1.1.0, a setting called hawkbit.ddi.skip-artifact-access-check.tenants can turn this check off per tenant, or for all tenants with *. It is empty by default, so the check stays on. Leave it empty unless you mean to turn the check off.
Reported by me. The advisory credits avgustinmm as remediation developer. Comments and corrections welcome.
Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). GitHub: @Santoshkumarpuppala
