Large CIDR expansion returns only one IP due to non-incremented loop variable #684
Labels
No labels
1week
2weeks
Failed compliance check
IP cameras
NATS
Possible security concern
Review effort 1/5
Review effort 2/5
Review effort 3/5
Review effort 4/5
Review effort 5/5
UI
aardvark
accessibility
amd64
api
arm64
auth
back-end
bgp
blog
bug
build
checkers
ci-cd
cleanup
cnpg
codex
core
dependencies
device-management
documentation
duplicate
dusk
ebpf
enhancement
eta 1d
eta 1hr
eta 3d
eta 3hr
feature
fieldsurvey
github_actions
go
good first issue
help wanted
invalid
javascript
k8s
log-collector
mapper
mtr
needs-triage
netflow
network-sweep
observability
oracle
otel
plug-in
proton
python
question
reddit
redhat
research
rperf
rperf-checker
rust
sdk
security
serviceradar-agent
serviceradar-agent-gateway
serviceradar-web
serviceradar-web-ng
siem
snmp
sysmon
topology
ubiquiti
wasm
wontfix
zen-engine
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
carverauto/serviceradar#684
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Imported from GitHub.
Original GitHub issue: #2146
Original author: @mfreeman451
Original URL: https://github.com/carverauto/serviceradar/issues/2146
Original created: 2025-12-16T05:16:44Z
Summary
collectIPsFromRangefunction inpkg/mapper/utils.gois responsible for expanding CIDR ranges into individual IP addresses for network discovery, with a limit of 256 IPs for large ranges (> /24).iis initialized but never incremented, while the function incrementsipbut usesi.String()to get the IP address.iremains constant throughout the loop.Code with bug
The bug is on lines 227-229. The loop declares
i := ipCopy.Mask(ipNet.Mask)in the initialization statement but incrementsipin the post-statement (incrementIP(ip)). The loop body then usesi.String()to get the IP address. Sinceiis never modified, it always returns the same IP address (the network address).Evidence
Example
Consider a user initiating discovery on CIDR range
192.168.0.0/16:ip = 192.168.0.0,ipNet.Mask = /16,hostBits = 16(> 8, so enters large range path)i = ipCopy.Mask(ipNet.Mask) = 192.168.0.0i.String()returns"192.168.0.0"targetsandseenmapcountbecomes 1incrementIP(ip)changesipto192.168.0.1(butistays192.168.0.0)i.String()still returns"192.168.0.0"(becauseiwas never incremented)seenmap, so not added totargetscountstays 1incrementIP(ip)changesipto192.168.0.2inever changes, so same IP is checked repeatedlyipNet.Contains(ip)becomes false or when the loop has run 256 timestargetscontains only["192.168.0.0"]instead of 256 unique IPsThe expected behavior would be to return 256 unique, sequential IP addresses:
192.168.0.0,192.168.0.1,192.168.0.2, ...,192.168.0.255.Failing test
Test script
Test output
The test clearly shows that instead of returning 256 IPs, the function only returns 1 IP.
Inconsistency within the codebase
Reference code
pkg/mapper/utils.golines 238-250 (small range path in same function):Current code
pkg/mapper/utils.golines 214-236 (large range path):Contradiction
The small range path (lines 238-250) correctly shadows the loop variable
ipin the for loop declaration and both increments and uses the same variable. The large range path (lines 214-236) attempts to do something similar but makes a critical error: it declares loop variableibut incrementsipinstead, then usesiin the loop body. This meansinever changes, defeating the purpose of the loop. The comment "changed from ip to i, to avoid modifying the original IP" suggests the developer's intent, but the implementation is incorrect becauseiis never actually incremented.Full context
The
collectIPsFromRangefunction is called byexpandCIDR(pkg/mapper/utils.go:326), which is in turn called byexpandSeeds(pkg/mapper/utils.go:291). TheexpandSeedsfunction is called at the beginning ofrunDiscoveryJob(pkg/mapper/discovery.go:1215) to convert user-provided CIDR ranges and individual IPs into a list of target IP addresses for network discovery.When a network administrator configures ServiceRadar to discover devices on a network, they typically provide CIDR ranges like
10.0.0.0/8,172.16.0.0/12, or192.168.0.0/16. For large networks (CIDR masks with > 8 host bits, i.e., more than 256 hosts), the code intentionally limits scanning to the first 256 IPs to avoid overwhelming the system.However, due to this bug, when a large CIDR range is provided:
This severely impacts the core functionality of the discovery engine, as network administrators expecting to discover up to 256 devices per large CIDR range will instead only scan a single IP (which is typically the network address itself and unlikely to be a valid host).
The bug affects any discovery job where users provide CIDR ranges larger than /24. Common use cases that would trigger this bug include:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16The small range path (≤ 256 hosts) works correctly, so this bug only manifests for larger networks, which explains why it may have gone undetected if testing focused on smaller subnets.
Why has this bug gone undetected?
Several factors explain why this bug has remained undetected in production:
Git history reveals the bug was introduced during refactoring: The commit
137dc9cb(WIP refactoring to fix agentID and pollerID propagation) introduced this code with the comment "changed from ip to i, to avoid modifying the original IP". This was part of a work-in-progress refactoring, suggesting the code may not have been thoroughly tested before being merged.No unit tests for CIDR expansion: There are no existing unit tests for
collectIPsFromRange,expandCIDR, orexpandSeedsfunctions. The test suite does not validate that large CIDR ranges are properly expanded into multiple IP addresses.The
seenmap masks the problem: Because the same IP is attempted to be added 256 times but theseenmap prevents duplicates, the function returns a single-element slice rather than crashing or producing obviously broken output. This makes the bug subtle and harder to notice in logs.Discovery may appear to complete successfully: The discovery job runs to completion and reports success, but with only 1 device scanned instead of up to 256. Without comparing the number of IPs generated against expectations, this looks like a normal completion.
Small networks work correctly: The bug only affects CIDR ranges with > 8 host bits (more than 256 hosts). Smaller ranges like
/24,/25,/26, etc. use the correct code path (lines 238-250) and work properly. If testing primarily used smaller subnets, the bug would not manifest.Real-world deployments may use smaller ranges: Organizations might partition their networks into smaller /24 subnets for discovery, inadvertently avoiding the bug. A network admin might configure multiple /24 ranges rather than a single /16, which would work correctly.
Limited large-scale testing: Testing network discovery on truly large CIDR ranges (like
10.0.0.0/8or192.168.0.0/16) requires either a large test network or careful validation of the IP list size. If such testing wasn't performed, the bug would remain hidden.Recommended fix
Change line 227 from:
To:
And change line 229 from:
To:
This makes the large range path consistent with the small range path (lines 242-249), where the loop variable is properly shadowed and both incremented and used within the loop body. The loop variable
ipshadows the function parameterip, which is acceptable in Go and prevents modification of the original parameter, achieving the intent expressed in the comment.