Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions floss/api_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,97 @@ def __call__(self, emu, api, argv):
return True


class SnprintfHook:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add some tests that demonstrate this routine works the way we think it does

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! I'd like to clarify what kind of tests you're looking for:

  • Unit tests - pytest tests that mock the emulator and verify SnprintfHook._prepare_args() and .call() work correctly with various format specifiers.

  • Integration tests - Tests that run FLOSS on an actual binary (like the C test file I shared) and verify decoded strings are extracted.

"""
Uses Python's native % formatting for simplicity.
Supports: %d, %s, %x, %X, %u, %c, %o, and width/precision modifiers.
"""

def _prepare_args(self, emu, fmt, argv, arg_start_idx):
args = []
arg_idx = arg_start_idx

# Find all format specifiers
i = 0
while i < len(fmt):
if fmt[i] == "%":
if i + 1 < len(fmt) and fmt[i + 1] == "%":
i += 2
continue

if arg_idx >= len(argv):
break
arg_val = argv[arg_idx]
arg_idx += 1

# Find the specifier type (last char of this format spec)
j = i + 1
while j < len(fmt) and fmt[j] in "0123456789+-# .hlL":
j += 1

if j < len(fmt):
spec_char = fmt[j]
if spec_char == "s":
# %s - read string from emulator memory
if isinstance(arg_val, int) and arg_val != 0:
try:
arg_val = fu.readStringAtRva(emu, arg_val, maxsize=MAX_STR_SIZE).decode(errors="ignore")
except Exception:
arg_val = ""
else:
arg_val = "(null)"
elif spec_char in "di":
# Signed types - convert from unsigned to signed if needed
if isinstance(arg_val, int) and arg_val > 0x7FFFFFFF:
arg_val = arg_val - 0x100000000 # Convert to signed 32-bit
elif spec_char in "xXuo":
# Unsigned types - mask to ensure positive
arg_val = arg_val & 0xFFFFFFFF

args.append(arg_val)
i = j + 1
else:
i += 1

return tuple(args)

def __call__(self, emu, api, argv):
if fu.contains_funcname(api, ("snprintf", "sprintf", "_snprintf", "_sprintf")):
try:
# snprintf(buf, size, fmt, ...) vs sprintf(buf, fmt, ...)
if fu.contains_funcname(api, ("sprintf",)) and not fu.contains_funcname(api, ("snprintf",)):
buf_ptr, fmt_ptr = argv[:2]
size = MAX_STR_SIZE
arg_start = 2
else:
buf_ptr, size, fmt_ptr = argv[:3]
arg_start = 3

fmt = fu.readStringAtRva(emu, fmt_ptr, maxsize=256).decode(errors="ignore")

args = self._prepare_args(emu, fmt, argv, arg_start)

# Use Python's native % formatting
try:
result = fmt % args
except (TypeError, ValueError):
result = fmt

result_bytes = result.encode("utf-8")
if size > 0:
result_bytes = result_bytes[: size - 1]
emu.writeMemory(buf_ptr, result_bytes + b"\x00")

fu.call_return(emu, api, argv, len(result_bytes))
return True

except Exception as e:
logger.warning(f"SnprintfHook error: {e}")
return False

return False


class PrintfHook:
# TODO disabled for now as incomplete (need to implement string format) and could result in FP strings as is
def __call__(self, emu, api, argv):
Expand Down Expand Up @@ -406,6 +497,7 @@ def __call__(self, emu, api, argv):
StrlenHook(),
MemchrHook(),
MemsetHook(),
SnprintfHook(),
# PrintfHook(), currently disabled, see comments above
StrncmpHook(),
GetLastErrorHook(),
Expand Down
2 changes: 1 addition & 1 deletion floss/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,5 +615,5 @@ def get_static_strings(sample: Path, min_length: int) -> list:
# windows
kwargs = {"access": mmap.ACCESS_READ}

with contextlib.closing(mmap.mmap(f.fileno(), 0, **kwargs)) as buf:
with contextlib.closing(mmap.mmap(f.fileno(), 0, **kwargs)) as buf: # type: ignore[arg-type]
return list(extract_ascii_unicode_strings(buf, min_length))
175 changes: 175 additions & 0 deletions tests/test_snprintf_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
from unittest.mock import MagicMock, patch

import pytest

from floss.api_hooks import MAX_STR_SIZE, SnprintfHook


class MockEmulator:
"""Mock emulator for testing hooks without vivisect dependency."""

def __init__(self):
self.memory = {}
self.return_value = None

def writeMemory(self, addr, data):
self.memory[addr] = data

def readMemory(self, addr, size):
return self.memory.get(addr, b"\x00" * size)[:size]


class TestSnprintfHook:
"""Tests for SnprintfHook._prepare_args and __call__"""

@pytest.fixture
def hook(self):
return SnprintfHook()

@pytest.fixture
def emu(self):
return MockEmulator()

def test_prepare_args_integer(self, hook, emu):
"""Test %d format specifier"""
fmt = "Value: %d"
argv = [0, 64, 0, 42] # buf, size, fmt, value
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == (42,)

def test_prepare_args_negative_integer(self, hook, emu):
"""Test %d with negative value (unsigned to signed conversion)"""
fmt = "Value: %d"
# 0xFFFFFF85 = 4294967173 (unsigned representation of -123)
argv = [0, 64, 0, 0xFFFFFF85]
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == (-123,)

def test_prepare_args_hex(self, hook, emu):
"""Test %x format specifier"""
fmt = "Hex: %x"
argv = [0, 64, 0, 255]
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == (255,)

def test_prepare_args_unsigned(self, hook, emu):
"""Test %u format specifier with large unsigned value"""
fmt = "Unsigned: %u"
argv = [0, 64, 0, 0xFFFFFFFF]
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == (0xFFFFFFFF,)

def test_prepare_args_string(self, hook, emu):
"""Test %s format specifier (reads from memory)"""
fmt = "Name: %s"
string_ptr = 0x1000
emu.memory[string_ptr] = b"TestString\x00"

argv = [0, 64, 0, string_ptr]

with patch("floss.api_hooks.fu.readStringAtRva") as mock_read:
mock_read.return_value = b"TestString"
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == ("TestString",)

def test_prepare_args_null_string(self, hook, emu):
"""Test %s with NULL pointer"""
fmt = "Name: %s"
argv = [0, 64, 0, 0] # NULL pointer
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == ("(null)",)

def test_prepare_args_multiple(self, hook, emu):
"""Test multiple format specifiers"""
fmt = "User: %s, Port: %d"
string_ptr = 0x1000

argv = [0, 64, 0, string_ptr, 8080]

with patch("floss.api_hooks.fu.readStringAtRva") as mock_read:
mock_read.return_value = b"admin"
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == ("admin", 8080)

def test_prepare_args_percent_escape(self, hook, emu):
"""Test %% escape sequence (should not consume argument)"""
fmt = "100%% complete: %d"
argv = [0, 64, 0, 42]
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == (42,)

def test_prepare_args_width_modifier(self, hook, emu):
"""Test format specifier with width modifier"""
fmt = "Padded: %08x"
argv = [0, 64, 0, 255]
args = hook._prepare_args(emu, fmt, argv, arg_start_idx=3)
assert args == (255,)


class TestSnprintfHookCall:
"""Tests for the full __call__ method"""

@pytest.fixture
def hook(self):
return SnprintfHook()

@pytest.fixture
def emu(self):
return MockEmulator()

def test_snprintf_basic(self, hook, emu):
"""Test snprintf call writes formatted string to memory"""
buf_ptr = 0x2000
fmt_ptr = 0x1000

api: tuple = (None, None, None, "snprintf", [])
argv = [buf_ptr, 64, fmt_ptr, 42]

with (
patch("floss.api_hooks.fu.contains_funcname") as mock_contains,
patch("floss.api_hooks.fu.readStringAtRva") as mock_read,
patch("floss.api_hooks.fu.call_return") as mock_return,
):

mock_contains.side_effect = lambda api, names: "snprintf" in names
mock_read.return_value = b"Value: %d"

result = hook(emu, api, argv)

assert result == True
assert buf_ptr in emu.memory
assert emu.memory[buf_ptr] == b"Value: 42\x00"

def test_sprintf_basic(self, hook, emu):
"""Test sprintf call (no size parameter)"""
buf_ptr = 0x2000
fmt_ptr = 0x1000

api: tuple = (None, None, None, "sprintf", [])
argv = [buf_ptr, fmt_ptr, 1337]

with (
patch("floss.api_hooks.fu.contains_funcname") as mock_contains,
patch("floss.api_hooks.fu.readStringAtRva") as mock_read,
patch("floss.api_hooks.fu.call_return") as mock_return,
):

# Mock: first call checks if any of snprintf/sprintf
# Second call checks if "sprintf" is in names
# Third call checks if "snprintf" is in names (should return False for sprintf)
def contains_check(api, names):
# Check if "sprintf" is in the tuple (matches sprintf, snprintf,)
if "sprintf" in names:
return True
if "snprintf" in names:
return False # sprintf is NOT snprintf
return False

mock_contains.side_effect = contains_check
mock_read.return_value = b"Code: %d"

result = hook(emu, api, argv)

assert result == True
assert buf_ptr in emu.memory
assert emu.memory[buf_ptr] == b"Code: 1337\x00"
Loading