Description
call_indirect
has been a very useful feature for WebAssembly. However, the efficiency and good behavior of the instruction has implicitly relied on the simplicity of wasm's type system. In particular, every wasm value has exactly one (static) type it belongs to. This property conveniently avoids a number of known problems with untyped function calls in typed languages. But now that wasm is extending beyond numeric types, it has gotten to the point where we need to understand these problems and keep them in mind.
call_indirect
fundamentally works by comparing the caller's expected signature against the callee's defined signature. With just numeric types, WebAssembly had the property that these signatures were equal if and only if a direct call to the function referenced by the corresponding funcref
would have type-checked. But there are two reasons that will soon not be true:
- With subtyping, a direct call would work so long as the actual function's defined signature is a "sub-signature" of the expected signature, meaning all the input types are subtypes of the function's parameter types and all the output types are supertypes of the function's result types. This means that an equality check between an indirect call's expected signature and the function's defined signature would trap in a number of perfectly safe situations, which might be problematic for supporting languages with heavy use of subtyping and indirect calls (as was raised during the discussion on deferring subtyping). It also means that, if a module intentionally exports a function with a weaker signature than the function was defined with, then
call_indirect
can be used to access the function with its private defined signature rather than just its weaker public signature (an issue that was just discovered and so has not yet been discussed). - With type imports, a module can export a type without exporting the definition of that type, providing abstraction that systems like WASI plan to heavily rely upon. That abstraction prevents other modules from depending on its particular definition at compile time. But at run time the abstract exported type is simply replaced with its definition. This is important, for example, for enabling
call_indirect
to work properly on exported functions whose exported signatures reference that exported type. However, if a malicious module knows what the definition of that exported type is, they can usecall_indirect
to convert back and forth between the exported type and its intended-to-be-secret definition becausecall_indirect
only compares signatures at run time, when the two types are indeed the same. Thus a malicious module can usecall_indirect
to access secrets meant to be abstracted by the exported type, and can usecall_indirect
to forge values of the exported type that may violate security-critical invariants not captured in the definition of the type itself.
In both of the above situations, call_indirect
can be used to bypass the abstraction of a module's exported signature. As I mentioned, so far this hasn't been a concern because wasm only had numeric types. And originally I thought that, by deferring subtyping, all concerns regarding call_indirect
had also effectively been deferred. But what I recently realized is that, by removing subtyping, the "new" type (named externref
in WebAssembly/reference-types#87) is effectively a stand-in for an abstract type import. If that's what people would like it to actually be, then unfortunately we need to take into consideration the above interaction between call_indirect
and type imports.
Now there are many potential ways to address the above issues with call_indirect
, but each has its tradeoffs, and it is simply much too large a design space to be able to come to a decision on quickly. So I am not suggesting that we solve this problem here and now. Rather, the decision to be made at the moment is whether to buy time to solve the problem properly with respect to externref
. In particular, if we for now restrict call_indirect
and func.ref
to only type-check when the associated signature is entirely numeric, then we serve all the core-wasm use cases of indirect calls and at the same time leave room for all the potential solutions to the above issues. However, I do not know if this restriction is practical, both in terms of implementation effort and in terms of whether it obstructs the applications of externref
that people are waiting for. The alternative is to leave call_indirect
and func.ref
as is. It is just possible that this means that, depending on the solution we arrive at, externref
might not be instantiable like a true type import would be, and/or that externref
might (ironically) not be able to have any supertypes (e.g. might not be able to be a subtype of anyref
if we do eventually decide to add anyref
).
I, speaking for just myself, consider both options manageable. While I do have a preference, I am not strongly pushing the decision to go one way or the other, and I believe y'all have better access to the information necessary to come to a well-informed decision. I just wanted y'all to know that there is a decision to be made, and at the same time I wanted to establish awareness of the overarching issue with call_indirect
. If you would like a more thorough explanation of that issue than what the summary above provides, please read the following.
call_indirect
versus Abstraction, in Detail
I'll use the notation call_indirect[ti*->to*](func, args)
, where [ti*] -> [to*]
is the expected signature of the function, func
is simply a funcref (rather that a funcref table and an index), and args
are the to*
values to pass to the function. Similarly, I'll use call($foo, args)
for a direct call of the function with index $foo
passing arguments args
.
Now suppose $foo
is the index of a function with declared input types ti*
and output types to*
. You might expect that call_indirect[ti*->to*](ref.func($foo), args)
is equivalent to call($foo, args)
. Indeed, that is the case right now. But it's not clear that we can maintain that behavior.
call_indirect
and Subtyping
One example potential problem came up in the discussion of subtyping. Suppose the following:
tsub
is a subtype oftsuper
- module instance IA exports a function
$fsub
that was defined with type[] -> [tsub]
- module MB imports a function
$fsuper
with type[] -> [tsuper]
- module instance IB is module MB instantiated with IA's
$fsub
as$fsuper
(which is sound to do—even if it's not possible now, this issue is about potential upcoming problems)
Now consider what should happen if IB executes call_indirect[ -> tsuper](ref.func($fsuper))
. Here are the two outcomes that seem most plausible:
- The call succeeds because the expected signature and defined signature are compatible.
- The call traps because the two signatures are distinct.
If we were to choose outcome 1, realize that we would likely need to employ one of two techniques to make this possible:
- For imported functions, have
call_indirect
compare with the import signature rather than the definition signature. - Do an at-least-linear-time run-time check for subtype-compatibility of the expected signature and the definition signature.
If you prefer technique 1, realize that it won't work once we add Typed Function References (with variant subtyping). That is, func.ref($fsub)
will be a ref ([] -> [tsub])
and also a ref ([] -> [tsuper])
, and yet technique 1 will not be sufficient to keep call_indirect[ -> super](ref.func($fsub))
from trapping. This means outcome 1 likely requires technique 2, which has concerning performance implications.
So let's consider outcome 2 a bit more. The implementation technique here is to check if the expected signature of the call_indirect
in IB is equal to the signature of the definition of $fsub
in IA. At first the major downside of this technique might seem to be that it traps on a number of calls that are safe to execute. However, another downside is that it potentially introduces a security leak for IA.
To see how, let's switch up our example a bit and suppose that, although instance IA internally defines $fsub
to have type [] -> [tsub]
, instance IA only exports it with type [] -> [tsuper]
. Using the technique for outcome 2, instance IB can (maliciously) execute call_indirect[ -> tsub]($fsuper)
and the call will succeed. That is, IB can use call_indirect
to circumvent the narrowing IA did to its function's signature. At best, that means IB is dependent on an aspect of IA that is not guaranteed by IA's signature. At worst, IB can use this to access internal state that IA might have intentionally been concealing.
call_indirect
and Type Imports
Now let's put subtyping aside and consider type imports. For convenience, I am going to talk about type imports, rather than just reference-type imports, but that detail is inconsequential. For the running example here, suppose the following:
-
module instance IC defines a type
capability
and exports the type but not its definition as$handle
-
module instance IC exports a function
$do_stuff
that was defined with type[capability] -> []
but exported with type[$handle] -> []
-
module MD imports a type
$extern
and a function$run
with type[$extern] -> []
-
module instance ID is module MD instantiated with IA's exported
$handle
as$extern
and with IA's exported$do_stuff
as$run
What this example sets up is two modules where one module does stuff with the other module's values without knowing or being allowed to know what those values are. For example, this pattern is the planned basis for interacting with WASI.
Now let's suppose instance ID has managed to get a value e
of type $extern
and executes call_indirect[$extern -> ](ref.func($run), e)
. Here are the two outcomes that seem most plausible:
- The call succeeds because the expected signature and defined signature are compatible.
- The call traps because the two signatures are distinct.
Outcome 2 makes call_indirect
pretty much useless with imported types. So for outcome 1, realize that the input type $extern
is not the defined input type of $do_stuff
(which instead is capability
), so we would likely need to use one of two techniques to bridge this gap:
- For imported functions, have
call_indirect
compare with the import signature rather than the definition signature. - Recognize that at run time the type
$extern
in instance ID representscapability
.
If you prefer technique 1, realize that it once again won't work once we add Typed Function References. (The fundamental reason is the same as with subtyping, but it'd take even more text to illustrate the analog here.)
That leaves us with technique 2. Unfortunately, once again this presents a potential security issue. To see why, suppose ID is malicious and wants to get at the contents of $handle
that IC had kept secret. Suppose further that ID has a good guess as to what $handle
really represents, namely capability
. ID can define the identity function $id_capability
of type [capability] -> [capability]
. Given a value e
of type $extern
, ID can then execute call_indirect[$extern -> capability](ref.func($id_capability), e)
. Using technique 2, this indirect call will succeed because $extern
represents capability
at run time, and ID will get the raw capability
that e
represents back. Similarly, given a value c
of type capability
, ID can execute call_indirect[capability -> $extern](ref.func($id_capability), c)
to forge c
into an $extern
.
Conclusion
Hopefully I've made it clear that call_indirect
has a number of significant upcoming performance, semantic, and/or security/abstraction issues—issues that WebAssembly has been fortunate to have avoided so far. Unfortunately, due to call_indirect
being part of core WebAssembly, these issues crosscut a number of proposals in progress. At the moment, I think it would be best to focus on the most pressing such proposal, Reference Types, where we need to decide whether or not to restrict call_indirect
and func.ref
to only numeric types for now—a restriction we might be able to relax depending on how we eventually end up solving the overarching issues with call_indirect
.
(Sorry for the long post. I tried my best to explain complex interactions of cross-module compile-time-typing-meets-run-time-typing features and demonstrate the importance of those interactions as concisely as possible.)