Skip to content

memcpy/memset through a pointer that may point to one of two arrays of different sizes reads and writes the wrong bytes (spurious failures and unsound results) #9237

Open

Description

@frederickjjoubert

Disclaimer: this issue was investigated and written by Claude Opus 5.5 (an AI model), run by a user of Kani. The repros and outputs below were run as shown.

CBMC version: 6.11.0 (cbmc-6.11.0); the code involved is unchanged on develop (8fcda33)
Operating system: macOS 26.6.2, arm64
Exact command line resulting in the issue: cbmc spurious.c, cbmc unsound.c

When the source or destination pointer of memcpy (or the destination of memset) may point to either of two arrays of different sizes, the copy uses the wrong bytes of one of them. The result depends on which object ends up in the true branch of the dereferenced if. It produces spurious counterexamples and also proves false assertions.

Spurious failure (spurious.c):

#include <string.h>
#include <assert.h>
_Bool nondet_bool(void);
int main(void) {
  char x[2] = {1, 2};
  char y[1] = {1};
  _Bool c = nondet_bool();
  char *p = c ? x : y;
  unsigned long n = c ? 2 : 1;
  char buf[4] = {0};
  memcpy(buf, p, n);
  if (c) assert(buf[1] == 2);
  return 0;
}

What behaviour did you expect: VERIFICATION SUCCESSFUL.
What happened instead: [main.assertion.1] line 12 assertion buf[1] == 2: FAILURE. The trace shows src_n={ 1, 0 } copied from x = { 1, 2 }. Swapping the branches (p = c ? y : x, n = c ? 1 : 2, assert under !c) verifies.

Unsound result (unsound.c), a false claim that verifies:

#include <string.h>
#include <assert.h>
_Bool nondet_bool(void);
int main(void) {
  char x[2] = {0, 0};
  char y[1] = {0};
  char src[2] = {1, 2};
  _Bool c = nondet_bool();
  char *p = c ? x : y;
  unsigned long n = (p == x) ? 2 : 1;
  memcpy(p, src, n);
  if (p == x) assert(x[1] == 0); /* false: memcpy wrote 2 there */
  return 0;
}

What behaviour did you expect: VERIFICATION FAILED.
What happened instead: VERIFICATION SUCCESSFUL. With the branches swapped it fails as it should. memset(p, 7, n) in place of the memcpy behaves the same way: assert(x[1] == 7) fails spuriously in this branch order.

Where it seems to come from: process_array_expr in src/goto-symex/symex_clean_expr.cpp. When the dereferenced pointer becomes p == &y ? y : x, the ID_if case gives the if the type of the true case and byte-extracts the false case to that type:

byte_extract_exprt be = make_byte_extract(
  if_expr.false_case(), from_integer(0, c_index_type()), if_expr.true_case().type());
if_expr.false_case().swap(be);
...
if_expr.type()=if_expr.true_case().type();

When the true case is the smaller array (char[1]), the larger one (char[2]) is cut to one byte. array_copy then extracts that char[1] back up to the destination's char[2], so the second byte is unconstrained. On the destination side, the write to the larger array covers only the smaller array's size, and the rest keeps its old value. That is the unsound case. This is the code path for __CPROVER_array_copy, __CPROVER_array_replace and __CPROVER_array_set (symex_other.cpp), which the library's memcpy and memset use. #8176 is in the same area but involves two dynamic objects of equal symbolic size.

Found through Kani, where copy_from_slice or copy_nonoverlapping between slices that may point to either of two arrays shows the same behaviour.

Activity

  1. repowazdogz-droid commented on Nov 3, 2026

    @repowazdogz-droid

    I reproduced this independently, checking CBMC's verdict against concrete execution.

    False proof. With the issue's unsound.c shape:

    #include <string.h>
    #include <assert.h>
    _Bool nondet_bool(void);
    int main(void) {
      char x[2] = {0, 0};
      char y[1] = {0};
      char src[2] = {1, 2};
      _Bool c = nondet_bool();
      char *p = c ? x : y;
      unsigned long n = (p == x) ? 2 : 1;
      memcpy(p, src, n);
      if (p == x) assert(x[1] == 0); /* false: memcpy wrote 2 there */
      return 0;
    }
    • CBMC 6.11.0 (Homebrew) and develop at 03bd0722: assertion x[1] == 0: SUCCESS, VERIFICATION SUCCESSFUL.
    • The same file built with clang -O0 -fsanitize=address,undefined and nondet_bool() returning 1: Assertion failed: (x[1] == 0), exit 134. With 0 it exits 0.

    spurious.c and the memset variant gave the reverse (CBMC FAILED, both concrete runs exit 0). The swapped and equal-size forms agree with execution.

    Kani. The unsound_copy_into_either_of_two_arrays harness from model-checking/kani#5065, run with Kani 0.67.0 (which bundles CBMC 6.8.0), also reports VERIFICATION SUCCESSFUL, while a plain cargo test of the same body gives x[1] == 2. So it predates 6.11.0.

    Where. This matches the account in the issue. In process_array_expr (src/goto-symex/symex_clean_expr.cpp, lines 42 to 50 at 03bd0722) the false case is byte-extracted to the true case's type and the if takes that type, so when the true case is the smaller object the larger one is truncated. memcpy, memmove and memset all reach it through array_copy, array_replace and array_set.

    Why I'm not proposing a patch. I tried extracting both branches to a covering type (the larger one for constant sizes, a byte array of the maximum size for symbolic sizes). It fixes the three programs above, but on its own it is not safe:

    #include <assert.h>
    #include <string.h>
    
    _Bool nondet_bool(void);
    
    int main(void)
    {
      unsigned char src[4] = {100, 101, 102, 103};
      _Bool c = nondet_bool();
      int t0[1] = {0};
      char t1[1] = {20};
      void *p = c ? (void *)t0 : (void *)t1;
      unsigned long n = c ? 4 : 1;
      memcpy(p, src, n);
      if(c)
        assert(((unsigned char *)t0)[0] == 0);
      return 0;
    }
    • Under --no-simplify it hits an invariant failure on constant-offset writes. Simplifying that size removed the abort but turned those programs into false proofs and false failures.

    My attempted repair is not sound in isolation. Combining it with #9380 eliminated the additional false proofs in my tested corpus, but I have not established that this combination is generally sound. I have not tested #9187.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions