Skip to content

Test: add adversarial kv_* host intrinsic probes#308

Merged
heifner merged 3 commits into
masterfrom
feature/kv-intrinsic-probe-tests
May 13, 2026
Merged

Test: add adversarial kv_* host intrinsic probes#308
heifner merged 3 commits into
masterfrom
feature/kv-intrinsic-probe-tests

Conversation

@heifner
Copy link
Copy Markdown
Contributor

@heifner heifner commented Apr 23, 2026

Follow-on to PR #306 (feature/kv-iterator-pool-split). Adds a dedicated probe contract and Boost driver that exercise the 22 kv_* host intrinsics directly via extern "C" + sysio_wasm_import, bypassing CDT's kv_multi_index / kv::table wrappers. The goal is coverage of the raw host ABI under inputs CDT cannot emit: zero-length spans, forged handles, cross-pool handles, negative primary_ids, table_ids above the uint16 namespace, oversize buffers, and so on.

What this pins

  • Every SYS_ASSERT in apply_context's kv paths has a dedicated rejection probe, including all three "missing entry" guards on kv_erase / kv_idx_remove / kv_idx_update, all three negative-primary_id guards, and the cross-contract primary->code == receiver guard.
  • Full cross-pool handle rejection matrix. kv_it_value and kv_it_status accept both handle kinds by design, and a positive behavior test pins the dual-dispatch so a future "normalization" that locks either intrinsic to primary-only would fail loud.
  • Boundary-accepted paths pinned at exactly max_kv_key_size = 256 and max_kv_value_size = 256 KiB.
  • Host-level invariants a refactor could silently change: dangling secondary after primary erase (host does NOT auto-cascade), cross-table primary_id permissiveness (kv_idx_store checks only primary->code, not table_id), inline-action iterator isolation, both iterator pools capped at 1024.
  • Chain-takedown attack classes audited and documented in the commit message: buffer overflow, heap exhaustion, CPU starvation, nondeterminism, integer overflow, notify-handler iterator sharing, payer = nonexistent account, kv_idx_update with cross-contract primary_id.

See the commit message for the full per-category breakdown and the rationale for each ruled-out class.

Refs

PR #270 covers RAM billing and boundary cases through CDT's wrappers, but nothing in-tree drives the raw host ABI. PRs #304 (feature/kv-secondary-primary-id) and #306 (feature/kv-iterator-pool-split) add new defensive checks - reserved-bit guard, cross-pool dispatch, independent slot pools - each of which is a regression surface a CDT-level suite cannot probe. This is the companion coverage.

Scope

Test-only. No production-code changes. Two modified wiring lines: one add_subdirectory in unittests/test-contracts/CMakeLists.txt, one MAKE_READ_WASM_ABI in unittests/test_contracts.hpp.in.

heifner added a commit that referenced this pull request May 11, 2026
Background
----------
PR #308 (feature/kv-intrinsic-probe-tests) added adversarial probes for the 22 kv_* host intrinsics by driving the raw host ABI with inputs CDT's kv_multi_index / kv::table wrappers would never emit. This PR is the companion suite covering the remaining non-kv host intrinsics: the crypto family (sha1/sha256/sha512/ripemd160 + assert_*, recover_key/assert_recover_key), the 128-bit integer and float128 compiler_builtins, preactivate_feature, the privileged_check-gated resource / blockchain-parameters ops, and the console / action-data legacy_span readers.

Motivation is the forthcoming legacy_span/legacy_ptr -> span cleanup. That cleanup touches every intrinsic in the list above, so the invariants the current implementation relies on -- alignment-proxy copy-in/out paths for legacy_ptr<fc::sha*> / legacy_ptr<int64_t, 8> / legacy_ptr<__int128>, zero-length-span acceptance, "query required size with buffer=0" semantics, SYS_ASSERT-throws-vs-returns-sentinel contracts -- need to be pinned before the refactor lands.

Layout
------
unittests/test-contracts/intrinsic_probe/intrinsic_probe.cpp declares every host intrinsic it probes via extern "C" + __attribute__((sysio_wasm_import)). The few that conflict with sysio::internal_use_do_not_use wrappers CDT already declares (prints, sysio_assert, send_inline, get_action, read_transaction, etc.) are reached through a raw:: alias. One [[sysio::action]] per probe -- 97 probes total across sha family (16), assert_sha family (16), recover_key family (13), preactivate_feature (3), compiler_builtins int128 (12), compiler_builtins float128 (9), resource/auth/producer/blockchain (11), console/IO (17).

unittests/intrinsic_probe_tests.cpp deploys the contract to both a non-privileged account (intprobe) and a privileged account (intprobe2); each BOOST_FIXTURE_TEST_CASE routes to the appropriate account based on whether the intrinsic under test is privileged_check-gated. Shared validating_tester singleton avoids re-paying bios setup per case. Setup policy is preactivate_feature_and_new_bios so set_privileged() can route through sysio.bios::setpriv and reserved_first_protocol_feature remains unactivated for the preactok probe.

Coverage
--------
Crypto -- four probes per hash family (golden, empty input, 1KB input, unaligned out-ptr forcing argument_proxy<fc::sha*, 8> copy-out path), four per assert_* family (correct match, zero-hash mismatch rejection, empty input with empty hash golden, unaligned const-hash ptr forcing copy-in path).

recover_key / assert_recover_key -- each of the distinct failure paths is isolated: empty sig (fc::raw::unpack runs dry), short sig (truncation mid-shim), bad variant tag (fc::raw variant unpack out-of-range), bad recovery byte (elliptic_secp256k1.cpp FC_THROW_EXCEPTION "unable to reconstruct public key"), bad r/s (math either succeeds with a different pub or fails -- both acceptable, probe and driver handle both), K1 small-pub buffer (fc::datastream FC_ASSERT for fixed-size key types), unaligned digest (argument_proxy copy-in path). assert_recover_key adds the type-mismatch and recovered-pub-mismatch SYS_ASSERT paths separately.

compiler_builtins -- each intrinsic probed with golden, unaligned out-ptr (argument_proxy<__int128*, 16> / argument_proxy<float128_t*, 16> copy-out path), and edge values (U64 carry in __multi3, INT128 shift >= 128 in __ashlti3, divide-by-zero in __div[u]ti3, NaN in __multf3, 1.0/0.0 in __divtf3, 2^127 overflow in __fixtfti).

Privileged gates -- three probes per priv-gated family: the accepted priv path, the non-priv rejection (unaccessible_api), and either a body-level rejection (preactivate_feature with bogus digest) or an idempotent in-priv probe (setreslim reads current limits, bumps, restores so shared-tester state is unaffected).

P2 -- get_resource_limits with aligned and unaligned int64_t out-ptr trios (the aligned-proxy copy-back path is the part most likely to regress under the cleanup), get_blockchain_parameters_packed with size=0 and with a buffer sized to the size query, get_active_producers with a sufficient buffer and with size=0, set_* probes for each family's non-priv rejection.

P3 -- prints (null_terminated_ptr) and prints_l (legacy_span) both covered; sysio_assert vs sysio_assert_message both covered in firing and non-firing variants; sysio_assert_message with empty span pinned (not crashed); read_action_data with zero size pinned (returns 0, doesn't overflow); read_transaction and get_blockchain_parameters_packed both pin the "size=0 returns required bytes" contract; get_action with invalid type pinned to throw action_not_found_exception rather than return -1 (its return-(-1) path is only for valid-type / out-of-range-index); send_inline with an empty span pinned to throw during action unpack (no silent no-op).

Host behaviors pinned for regression
------------------------------------
- assert_sha family throws crypto_api_exception "hash mismatch" via SYS_ASSERT; not a generic fc::exception. The separate exception-cleanup follow-on noted below can tighten the other recover_key paths toward the same shape; this pin gives that refactor a baseline.
- recover_key for K1 with a pub buffer smaller than 34 bytes throws (fc::datastream fixed-size pack FC_ASSERT). Variable-size keys (ed, wa) silently truncate via memcpy -- an API inconsistency worth flagging to the follow-on cleanup but NOT a regression surface today (not reached with the K1 test vector this suite uses).
- apply_context::get_action with type not in {0, 1} throws action_not_found_exception. The -1 sentinel applies only to valid-type / out-of-range-index.
- __divti3 / __udivti3 throw arithmetic_exception on rhs==0 (SYS_ASSERT in compiler_builtins.cpp).
- __ashlti3 with shift >= 128 returns 0 -- well-defined saturation, not UB.
- __divtf3(1.0, 0.0) returns +Inf, not a throw -- IEEE 754 semantics preserved through softfloat.
- privileged_check fires BEFORE the host body, so non-priv-rejection probes never reach their digest / buffer-content validation -- pinned uniformly across preactivate_feature / set_resource_limits / set_proposed_producers / set_blockchain_parameters_packed.
- sysio_assert and sysio_assert_message both throw sysio_assert_message_exception on test==0, NOT sysio_assert_code_exception (those are distinct intrinsics with distinct exception types).

Exception-cleanup follow-on (not in this PR)
--------------------------------------------
recover_key / assert_recover_key currently surface three categories of failure with three different exception types: structural unpack errors leak as fc::exception, secp256k1 math errors leak as fc::assert_exception, and SYS_ASSERT-gated errors are the only ones that produce a typed crypto_api_exception. A follow-on should wrap fc::raw::unpack and public_key::recover calls in try/catch and rethrow as crypto_api_exception with a descriptive message so contracts and oncall can catch a single typed exception for "signature recovery failed". The small-pub-buffer inconsistency (fixed-size keys throw, variable-size keys truncate silently) should be normalized in the same PR. This probe suite pins current behavior; when the follow-on lands, several BOOST_CHECK_THROW types here tighten from fc::exception to crypto_api_exception.

Verification
------------
97 test cases x 3 WASM runtimes (sys-vm, sys-vm-jit, sys-vm-oc) = 291 invocations, all green.

Refs
----
Companion to PR #308 (feature/kv-intrinsic-probe-tests). Same layout: dedicated test-contract directory, extern "C" raw imports at the call site for ABI explicitness, one [[sysio::action]] per probe, shared-tester singleton for cost amortization, pre-built .wasm/.abi committed alongside source.
Background
----------
PR #270 covers RAM billing and boundary cases through CDT's kv_multi_index / kv::table wrappers, but nothing in-tree drives the 22 kv_* host intrinsics with inputs that CDT cannot emit. A malicious contract can skip CDT and call the raw host ABI with zero-length spans, forged iterator handles, cross-pool handles, negative primary_ids, table_ids above the uint16 namespace, oversize buffers, etc. This suite exercises each defensive check at the host-intrinsic boundary and pins several behaviors that a future refactor could silently change.

Layout
------
unittests/test-contracts/kv_intrinsic_probe/kv_intrinsic_probe.cpp declares all 22 intrinsics via extern "C" + __attribute__((sysio_wasm_import)). Listing the ABI at the call site (not via <sysio/kv.h>) makes every adversarial input explicit and trivial to mutate. One [[sysio::action]] per probe, each using a distinct table_id so the shared tester is safe.

unittests/kv_intrinsic_probe_tests.cpp deploys the contract to kvprobe (and kvprobe2 for the cross-contract case) and runs each action. Accepted-behavior actions use BOOST_CHECK_NO_THROW; rejection probes use BOOST_CHECK_THROW with the specific exception type the host is expected to raise.

Coverage
--------
Every SYS_ASSERT in apply_context's kv paths has a dedicated rejection probe, including the three "missing entry" guards (kv_erase, kv_idx_remove, kv_idx_update), the three negative-primary_id guards, and the cross-contract primary->code == receiver guard. The full cross-pool handle matrix is covered for every rejecting intrinsic.

Accepted boundaries pinned: max_kv_key_size = 256 and max_kv_value_size = 256 KiB both round-trip; zero-length value and zero-length secondary key are legal; empty iterator prefix is legal; payer = 0 bills receiver; overlapping key/value spans deep-copied.

Dual-dispatch finding
---------------------
kv_it_value and kv_it_status accept both primary AND secondary handles by design - their implementations dispatch on the secondary-tag bit rather than asserting on it. An early draft of the suite assumed all primary kv_it_* reject secondary handles; the suite caught the wrong assumption. A positive behavior test (itvalsec) now pins the dual-dispatch so a future "normalization" that locks either intrinsic to primary-only would fail here.

Host behaviors pinned for regression
------------------------------------
  - Dangling secondary: erasing a primary does NOT auto-remove secondary entries referencing it. The entries remain findable and updatable; kv_idx_primary_key reports iterator_erased. Cleanup is the contract's responsibility. Auto-cascade would be a protocol change.
  - Cross-table primary_id: kv_idx_store checks only primary->code == receiver, NOT the primary's table_id. A secondary entry at table S may reference a primary row in an unrelated primary table P (same receiver). Convention, not enforcement.
  - Inline-action iterator isolation: inline actions receive a fresh apply_context with an empty iterator pool; a handle allocated by the parent is meaningless to the child.
  - Iterator pool limits: the primary pool and the independent secondary pool each cap at 1024 slots; the 1025th allocation raises kv_iterator_limit_exceeded.

Chain-takedown classes considered and ruled out
-----------------------------------------------
Explicit audit recorded so future probes do not re-investigate: host buffer overflow (legacy_span bounds-checked at the WASM-host boundary); host heap exhaustion (all pools and buffers bounded; apply_context destroyed on action exit); CPU starvation (trx_context.checktime during the _account_ram_deltas loop); divergent consensus (no floats / time / randomness in kv_*); integer overflow in offset/size math (copy_value_window gates with offset < src_size; checked_table_id caps at uint16); notify handler reading parent's iterators (shared apply_context but parent's iterators are dead state by then); payer = nonexistent account (has_authorization rejects any payer not in act->authorization; auth matrix owned by PR #270); kv_idx_update with cross-contract primary_id (composite lookup cannot find an entry kv_idx_store refused to create).
@heifner heifner force-pushed the feature/kv-intrinsic-probe-tests branch from e978119 to 0d6e666 Compare May 11, 2026 21:28
@heifner heifner changed the base branch from feature/kv-iterator-pool-split to refactor/kv-iterator-pool-split May 11, 2026 21:28
Base automatically changed from refactor/kv-iterator-pool-split to master May 13, 2026 15:39
@heifner heifner requested review from huangminghuang and removed request for brianjohnson5972 May 13, 2026 15:40
heifner added 2 commits May 13, 2026 11:23
PR #308 was originally stacked on the unmerged primary_id work. After rebase
onto master, probe contract had to be retargeted to master's
legacy_span pri_key host signatures.

Dropped: 5 primary_id-only probes (negative/missing pid, cross-contract pid).
Added: oversize pri_key probe, kv_it_value/kv_it_status on secondary handle
rejection probes.
Rewrote: prmera/danglng (master stores pri_key inline, no IT_ERASED on
primary erase); lbpast (use kv_idx_next for end-state check since
kv_it_status rejects sec handles); zval (kv_set returns RAM billable, not
a stable id).
@heifner heifner merged commit e31b082 into master May 13, 2026
28 checks passed
@heifner heifner deleted the feature/kv-intrinsic-probe-tests branch May 13, 2026 23:17
heifner added a commit that referenced this pull request May 13, 2026
Background
----------
PR #308 (feature/kv-intrinsic-probe-tests) added adversarial probes for the 22 kv_* host intrinsics by driving the raw host ABI with inputs CDT's kv_multi_index / kv::table wrappers would never emit. This PR is the companion suite covering the remaining non-kv host intrinsics: the crypto family (sha1/sha256/sha512/ripemd160 + assert_*, recover_key/assert_recover_key), the 128-bit integer and float128 compiler_builtins, preactivate_feature, the privileged_check-gated resource / blockchain-parameters ops, and the console / action-data legacy_span readers.

Motivation is the forthcoming legacy_span/legacy_ptr -> span cleanup. That cleanup touches every intrinsic in the list above, so the invariants the current implementation relies on -- alignment-proxy copy-in/out paths for legacy_ptr<fc::sha*> / legacy_ptr<int64_t, 8> / legacy_ptr<__int128>, zero-length-span acceptance, "query required size with buffer=0" semantics, SYS_ASSERT-throws-vs-returns-sentinel contracts -- need to be pinned before the refactor lands.

Layout
------
unittests/test-contracts/intrinsic_probe/intrinsic_probe.cpp declares every host intrinsic it probes via extern "C" + __attribute__((sysio_wasm_import)). The few that conflict with sysio::internal_use_do_not_use wrappers CDT already declares (prints, sysio_assert, send_inline, get_action, read_transaction, etc.) are reached through a raw:: alias. One [[sysio::action]] per probe -- 97 probes total across sha family (16), assert_sha family (16), recover_key family (13), preactivate_feature (3), compiler_builtins int128 (12), compiler_builtins float128 (9), resource/auth/producer/blockchain (11), console/IO (17).

unittests/intrinsic_probe_tests.cpp deploys the contract to both a non-privileged account (intprobe) and a privileged account (intprobe2); each BOOST_FIXTURE_TEST_CASE routes to the appropriate account based on whether the intrinsic under test is privileged_check-gated. Shared validating_tester singleton avoids re-paying bios setup per case. Setup policy is preactivate_feature_and_new_bios so set_privileged() can route through sysio.bios::setpriv and reserved_first_protocol_feature remains unactivated for the preactok probe.

Coverage
--------
Crypto -- four probes per hash family (golden, empty input, 1KB input, unaligned out-ptr forcing argument_proxy<fc::sha*, 8> copy-out path), four per assert_* family (correct match, zero-hash mismatch rejection, empty input with empty hash golden, unaligned const-hash ptr forcing copy-in path).

recover_key / assert_recover_key -- each of the distinct failure paths is isolated: empty sig (fc::raw::unpack runs dry), short sig (truncation mid-shim), bad variant tag (fc::raw variant unpack out-of-range), bad recovery byte (elliptic_secp256k1.cpp FC_THROW_EXCEPTION "unable to reconstruct public key"), bad r/s (math either succeeds with a different pub or fails -- both acceptable, probe and driver handle both), K1 small-pub buffer (fc::datastream FC_ASSERT for fixed-size key types), unaligned digest (argument_proxy copy-in path). assert_recover_key adds the type-mismatch and recovered-pub-mismatch SYS_ASSERT paths separately.

compiler_builtins -- each intrinsic probed with golden, unaligned out-ptr (argument_proxy<__int128*, 16> / argument_proxy<float128_t*, 16> copy-out path), and edge values (U64 carry in __multi3, INT128 shift >= 128 in __ashlti3, divide-by-zero in __div[u]ti3, NaN in __multf3, 1.0/0.0 in __divtf3, 2^127 overflow in __fixtfti).

Privileged gates -- three probes per priv-gated family: the accepted priv path, the non-priv rejection (unaccessible_api), and either a body-level rejection (preactivate_feature with bogus digest) or an idempotent in-priv probe (setreslim reads current limits, bumps, restores so shared-tester state is unaffected).

P2 -- get_resource_limits with aligned and unaligned int64_t out-ptr trios (the aligned-proxy copy-back path is the part most likely to regress under the cleanup), get_blockchain_parameters_packed with size=0 and with a buffer sized to the size query, get_active_producers with a sufficient buffer and with size=0, set_* probes for each family's non-priv rejection.

P3 -- prints (null_terminated_ptr) and prints_l (legacy_span) both covered; sysio_assert vs sysio_assert_message both covered in firing and non-firing variants; sysio_assert_message with empty span pinned (not crashed); read_action_data with zero size pinned (returns 0, doesn't overflow); read_transaction and get_blockchain_parameters_packed both pin the "size=0 returns required bytes" contract; get_action with invalid type pinned to throw action_not_found_exception rather than return -1 (its return-(-1) path is only for valid-type / out-of-range-index); send_inline with an empty span pinned to throw during action unpack (no silent no-op).

Host behaviors pinned for regression
------------------------------------
- assert_sha family throws crypto_api_exception "hash mismatch" via SYS_ASSERT; not a generic fc::exception. The separate exception-cleanup follow-on noted below can tighten the other recover_key paths toward the same shape; this pin gives that refactor a baseline.
- recover_key for K1 with a pub buffer smaller than 34 bytes throws (fc::datastream fixed-size pack FC_ASSERT). Variable-size keys (ed, wa) silently truncate via memcpy -- an API inconsistency worth flagging to the follow-on cleanup but NOT a regression surface today (not reached with the K1 test vector this suite uses).
- apply_context::get_action with type not in {0, 1} throws action_not_found_exception. The -1 sentinel applies only to valid-type / out-of-range-index.
- __divti3 / __udivti3 throw arithmetic_exception on rhs==0 (SYS_ASSERT in compiler_builtins.cpp).
- __ashlti3 with shift >= 128 returns 0 -- well-defined saturation, not UB.
- __divtf3(1.0, 0.0) returns +Inf, not a throw -- IEEE 754 semantics preserved through softfloat.
- privileged_check fires BEFORE the host body, so non-priv-rejection probes never reach their digest / buffer-content validation -- pinned uniformly across preactivate_feature / set_resource_limits / set_proposed_producers / set_blockchain_parameters_packed.
- sysio_assert and sysio_assert_message both throw sysio_assert_message_exception on test==0, NOT sysio_assert_code_exception (those are distinct intrinsics with distinct exception types).

Exception-cleanup follow-on (not in this PR)
--------------------------------------------
recover_key / assert_recover_key currently surface three categories of failure with three different exception types: structural unpack errors leak as fc::exception, secp256k1 math errors leak as fc::assert_exception, and SYS_ASSERT-gated errors are the only ones that produce a typed crypto_api_exception. A follow-on should wrap fc::raw::unpack and public_key::recover calls in try/catch and rethrow as crypto_api_exception with a descriptive message so contracts and oncall can catch a single typed exception for "signature recovery failed". The small-pub-buffer inconsistency (fixed-size keys throw, variable-size keys truncate silently) should be normalized in the same PR. This probe suite pins current behavior; when the follow-on lands, several BOOST_CHECK_THROW types here tighten from fc::exception to crypto_api_exception.

Verification
------------
97 test cases x 3 WASM runtimes (sys-vm, sys-vm-jit, sys-vm-oc) = 291 invocations, all green.

Refs
----
Companion to PR #308 (feature/kv-intrinsic-probe-tests). Same layout: dedicated test-contract directory, extern "C" raw imports at the call site for ABI explicitness, one [[sysio::action]] per probe, shared-tester singleton for cost amortization, pre-built .wasm/.abi committed alongside source.
heifner added a commit that referenced this pull request May 16, 2026
…pan-cleanup

Resolve 4 add/add conflicts in the intrinsic_probe test contract +
driver, all from the adversarial probes independently landing on master
via #308/#310. Took the PR-side (never-throw recover_key, matching the
merged crypto.cpp) and unioned in master's one orthogonal new probe
(gcfdcf / get_context_free_data_non_cf). Regenerated wasm/abi from the
merged contract source.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants