diff --git a/libopencas/libopencas.c b/libopencas/libopencas.c index a6ce51cc4..ef5db38b2 100644 --- a/libopencas/libopencas.c +++ b/libopencas/libopencas.c @@ -165,6 +165,8 @@ static int nl_resolve_family(struct nl_ctx *ctx, const char *name) genl->version = 1; nla = (struct nlattr *)((char *)buf + nlh->nlmsg_len); + if (strlen(name) + 1 > sizeof(buf) - nlh->nlmsg_len - NLA_HDRLEN) + return -EINVAL; nla->nla_type = CTRL_ATTR_FAMILY_NAME; nla->nla_len = NLA_HDRLEN + strlen(name) + 1; memcpy(nla_data(nla), name, strlen(name) + 1); diff --git a/tests/test_invariant_libopencas.py b/tests/test_invariant_libopencas.py new file mode 100644 index 000000000..c912250dc --- /dev/null +++ b/tests/test_invariant_libopencas.py @@ -0,0 +1,250 @@ +import pytest +import ctypes +import struct + + +# Simulated NLA buffer size constraint (typical netlink attribute max) +NLA_MAX_SIZE = 256 +NLA_HEADER_SIZE = 4 # nla_len (2 bytes) + nla_type (2 bytes) +NLA_DATA_MAX = NLA_MAX_SIZE - NLA_HEADER_SIZE + + +def simulate_nla_copy(name: str, buffer_size: int = NLA_DATA_MAX) -> bool: + """ + Simulates the vulnerable memcpy pattern: + memcpy(nla_data(nla), name, strlen(name) + 1) + + Returns True if the copy is safe (fits within buffer), False if overflow would occur. + This function models the SECURE version of the operation that MUST validate length. + """ + if not isinstance(name, str): + return False + + # strlen(name) + 1 includes null terminator + required_size = len(name.encode('utf-8')) + 1 + + # Security invariant: copy size must not exceed allocated buffer + if required_size > buffer_size: + return False # Would overflow - must be rejected + + # Simulate the actual copy into a fixed-size buffer + try: + buf = bytearray(buffer_size) + encoded = name.encode('utf-8') + b'\x00' + if len(encoded) > buffer_size: + return False + buf[:len(encoded)] = encoded + return True + except (ValueError, OverflowError, MemoryError): + return False + + +def safe_nla_name_copy(name: str, buffer_size: int = NLA_DATA_MAX) -> bytes: + """ + Secure implementation that MUST validate name length before copy. + Raises ValueError if name would overflow the NLA buffer. + """ + if not isinstance(name, str): + raise TypeError("name must be a string") + + encoded = name.encode('utf-8') + required = len(encoded) + 1 # +1 for null terminator + + if required > buffer_size: + raise ValueError( + f"Name length {required} exceeds NLA buffer size {buffer_size}: " + f"potential buffer overflow" + ) + + buf = bytearray(buffer_size) + buf[:len(encoded)] = encoded + buf[len(encoded)] = 0 # null terminator + return bytes(buf) + + +@pytest.mark.parametrize("payload", [ + # Exact boundary - should succeed + "A" * (NLA_DATA_MAX - 1), + # One byte over boundary - must be rejected + "A" * NLA_DATA_MAX, + # Massively oversized name + "A" * 1024, + "B" * 4096, + "C" * 65535, + # Null bytes embedded (potential bypass attempts) + "evil\x00" + "A" * 300, + # Unicode/multibyte characters that expand when encoded + "\u00e9" * 200, # é = 2 bytes each in UTF-8 + "\u4e2d" * 100, # Chinese char = 3 bytes each in UTF-8 + "\U0001f600" * 70, # Emoji = 4 bytes each in UTF-8 + # Format string-like payloads + "%s" * 200, + "%n%n%n%n%n" * 50, + # Path traversal / injection attempts + "../" * 100, + "/etc/passwd" + "A" * 300, + # Shell metacharacters + "$()" * 100, + "`id`" * 100, + # Very long single token + "x" * (NLA_MAX_SIZE * 2), + "x" * (NLA_MAX_SIZE * 10), + # Empty string (edge case - should succeed) + "", + # Single character (should succeed) + "a", + # Exactly at limit + "Z" * (NLA_DATA_MAX - 1), + # One over limit + "Z" * NLA_DATA_MAX, + # Two over limit + "Z" * (NLA_DATA_MAX + 1), + # Whitespace-heavy names + " " * 500, + "\t" * 500, + "\n" * 500, + # Mixed adversarial + "A" * 100 + "\x00" + "B" * 200, + "\xff" * 300, + "\x7f" * 300, +]) +def test_nla_name_copy_no_buffer_overflow(payload): + """ + Invariant: A name string copied into a netlink attribute (NLA) buffer + must NEVER exceed the allocated buffer size. The copy operation must + validate that strlen(name) + 1 <= buffer_size before performing the copy. + Any name that would cause strlen(name) + 1 > buffer_size MUST be rejected + with an error, never silently overflowing the buffer. + """ + buffer_size = NLA_DATA_MAX + + # Determine if this payload would overflow + try: + encoded_len = len(payload.encode('utf-8')) + except (UnicodeEncodeError, AttributeError): + encoded_len = len(payload) if isinstance(payload, bytes) else 0 + + required_size = encoded_len + 1 # +1 for null terminator + would_overflow = required_size > buffer_size + + if would_overflow: + # INVARIANT: Oversized names MUST raise an error, never silently overflow + with pytest.raises((ValueError, OverflowError, MemoryError, TypeError)): + safe_nla_name_copy(payload, buffer_size) + + # Also verify the simulation correctly identifies this as unsafe + result = simulate_nla_copy(payload, buffer_size) + assert result is False, ( + f"Security violation: name of encoded length {encoded_len} " + f"(+1 null = {required_size}) must not fit in buffer of size {buffer_size}. " + f"Buffer overflow would occur." + ) + else: + # INVARIANT: Valid-sized names must be accepted and copied correctly + result = safe_nla_name_copy(payload, buffer_size) + assert isinstance(result, bytes), "Copy must return bytes" + assert len(result) == buffer_size, "Buffer must be exactly buffer_size bytes" + + # Verify null termination + encoded = payload.encode('utf-8') + assert result[len(encoded)] == 0, "Copied name must be null-terminated" + + # Verify no data written beyond buffer bounds + assert len(result) <= buffer_size, ( + f"Result length {len(result)} must not exceed buffer size {buffer_size}" + ) + + +@pytest.mark.parametrize("name,buffer_size,should_succeed", [ + # Exact fit: name + null == buffer_size + ("A" * (NLA_DATA_MAX - 1), NLA_DATA_MAX, True), + # One byte too large + ("A" * NLA_DATA_MAX, NLA_DATA_MAX, False), + # Empty string always fits + ("", NLA_DATA_MAX, True), + # Tiny buffer + ("hello", 4, False), # 6 bytes needed, 4 available + ("hi", 4, False), # 3 bytes needed, 4 available -> fits + ("hi", 3, True), # 3 bytes needed, 3 available -> fits exactly + ("hi", 2, False), # 3 bytes needed, 2 available -> overflow + # Large buffer, large name + ("A" * 1000, 2048, True), + ("A" * 2048, 2048, False), # 2049 needed, 2048 available +]) +def test_nla_boundary_conditions(name, buffer_size, should_succeed): + """ + Invariant: The NLA buffer copy must correctly enforce boundary conditions. + Names that fit (strlen + 1 <= buffer_size) must succeed. + Names that don't fit must be rejected to prevent buffer overflow. + """ + if should_succeed: + result = safe_nla_name_copy(name, buffer_size) + assert result is not None + assert len(result) == buffer_size + encoded = name.encode('utf-8') + assert result[:len(encoded)] == encoded + assert result[len(encoded)] == 0 # null terminator present + else: + with pytest.raises((ValueError, OverflowError)): + safe_nla_name_copy(name, buffer_size) + + +def test_nla_copy_never_writes_beyond_buffer(): + """ + Invariant: Under no circumstances should the copy operation write + data beyond the end of the allocated NLA buffer. This is the core + security property preventing the buffer overflow vulnerability. + """ + buffer_size = NLA_DATA_MAX + + # Test with a range of sizes around the boundary + for extra in range(0, 100): + name_len = buffer_size - 1 + extra # starts at exact fit, goes over + name = "X" * name_len + required = name_len + 1 # +1 for null terminator + + if required <= buffer_size: + # Must succeed without overflow + result = safe_nla_name_copy(name, buffer_size) + assert len(result) == buffer_size, ( + f"Buffer size must be exactly {buffer_size}, got {len(result)}" + ) + else: + # Must be rejected + with pytest.raises((ValueError, OverflowError)): + safe_nla_name_copy(name, buffer_size) + + +def test_nla_copy_rejects_all_oversized_names(): + """ + Invariant: Every name whose strlen(name) + 1 > NLA_DATA_MAX + must be unconditionally rejected. No oversized name should ever + result in a successful copy that could overflow the buffer. + """ + oversized_names = [ + "A" * NLA_DATA_MAX, # exactly 1 byte over (null terminator) + "B" * (NLA_DATA_MAX + 1), # 2 bytes over + "C" * (NLA_DATA_MAX * 2), # double the limit + "D" * 65536, # very large + "\xff" * NLA_DATA_MAX, # binary data, oversized + ] + + for name in oversized_names: + encoded_len = len(name.encode('utf-8', errors='replace')) + required = encoded_len + 1 + + assert required > NLA_DATA_MAX, ( + f"Test setup error: name should be oversized but required={required} " + f"<= buffer={NLA_DATA_MAX}" + ) + + with pytest.raises((ValueError, OverflowError, MemoryError)): + safe_nla_name_copy(name, NLA_DATA_MAX) + + # Simulation must also flag this as unsafe + is_safe = simulate_nla_copy(name, NLA_DATA_MAX) + assert is_safe is False, ( + f"SECURITY VIOLATION: Oversized name (encoded_len={encoded_len}, " + f"required={required}) was not flagged as unsafe. " + f"This would cause a buffer overflow in the NLA copy operation." + ) \ No newline at end of file