Ted is writing things

On privacy, research, and privacy research.

Fun privacy bugs: Type confusion in bindings

When building a library of differential privacy primitives, you have two options. You can either re-implement all the basic logic, in which case you have to avoid all known pitfalls. Or you can simply write bindings to reuse an existing implementation from another language.

PyDP, a Python DP library maintained by OpenMined, chose the second path: the library is a thin wrapper around Google's C++ DP library. In theory, this limits the number of new things that can go wrong. But in practice, you might encounter different kinds of unexpected issues!

C++ has static types, and each function only works on arguments of a predefined type. Therefore, the C++ library defines two distinct AddNoise methods, with different input types: one to add integer noise, the other to add floating-point noise.

By contrast, Python's type system is dynamic: you can't predict the type of a variable just by reading the source code. Instead, you have to actually run the program and see what that variable contains. PyDP defines a single add_noise method which calls either C++ method depending on input type. This logic is implemented as follows.

template <typename T>
py::class_<dp::NumericalMechanism>& DefPyAddNoise(
    py::class_<dp::NumericalMechanism>& pyclass) {
  using FunctorType = T (dp::NumericalMechanism::*)(T);
  return pyclass.def("add_noise",
                     static_cast<FunctorType>(&dp::NumericalMechanism::AddNoise),
                     py::arg("result"));
}

// ...

DefPyAddNoise<int64_t>(numerical_mech);
DefPyAddNoise<double>(numerical_mech);

This code may look a bit obscure, but it does two simple things:

  • It declares a Python add_noise method on all NumericalMechanism classes.
  • It defines which C++ method to use when this method is called. Integers are converted to int64 then use the integer C++ method; floating-point numbers are converted to double then use the floating-point C++ method.

Except… Data analysis code doesn't always use Python's native types (int or float): often, using NumPy and its numeric types can be a lot more performant. Those allow users to specify the size of each numeric type, e.g. by using np.int16 or np.float32. How do the bindings handle such types?

The default logic of the underlying library checks two conditions, in order.

  1. First, it look at whether the value type matches the declared type. For integers, it checks whether it implements __index__. For floats, it checks whether it's a subclass of Python's built-in float.
  2. If the first pass fails, it look at whether the value can be converted to the declared type, using conversions methods like int(v) or float(v). In that case, the first declared binding with an available conversion is used.

The first condition is met for Python's int type and all NumPy integer types, which implement __index__. It also catches Python's float type and NumPy's np.float64: the latter is a subclass of float type, so the bindings correctly assign it to a C++ double.

However, what happens if a value v has type np.float32? This type is not a subclass of float, so the first condition doesn't apply! So the logic moves on to the second condition, and goes through the bindings in order. The first is the integer binding, so it checks whether it's possible to call int(v). This conversion exists, and simply uses truncation: for example, int(np.float32(1.7))==1. So our np.float32 value gets truncated, and is then perturbed using integer noise.

But this messes up all our DP calculations! If we're calculating a DP sum of small numbers, with clamping bounds of [0, 0.1], the sensitivity is 0.1. But adding a single record can move the total sum from 41.95 to 42.05; once truncated, the result goes from 41 to 42: the sensitivity gets multiplied by a factor of 10. Oops!

The cleanest solution is likely to use an option to prevent the binding library from automatically converting inputs. Alternatively, declaring the double binding first would also solve the issue: all the integer types would still hit the first pass, while np.float32 would be converted to double (a lossless conversion) in the second pass.

If you liked this article, you may enjoy the previous one on the same theme!

Feedback on these posts is welcome! Reach out via e-mail (se.niatnofsed@neimad) for comments and suggestions.
Interested in using privacy-enhancing technology to do more with your data, with respect and compliance built-in? I can help! Check out the website of my independent consultancy, Hiding Nemo, to learn more.