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 use call_indirect to convert back and forth between the exported type and its intended-to-be-secret definition because call_indirect only compares signatures at run time, when the two types are indeed the same. Thus a malicious module can use call_indirect to access secrets meant to be abstracted by the exported type, and can use call_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 of tsuper
- 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 represents capability.
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.)
call_indirecthas 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_indirectfundamentally 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 correspondingfuncrefwould have type-checked. But there are two reasons that will soon not be true:call_indirectcan 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).call_indirectto 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_indirectto convert back and forth between the exported type and its intended-to-be-secret definition becausecall_indirectonly compares signatures at run time, when the two types are indeed the same. Thus a malicious module can usecall_indirectto access secrets meant to be abstracted by the exported type, and can usecall_indirectto 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_indirectcan 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 regardingcall_indirecthad also effectively been deferred. But what I recently realized is that, by removing subtyping, the "new" type (namedexternrefin 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 betweencall_indirectand 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 toexternref. In particular, if we for now restrictcall_indirectandfunc.refto 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 ofexternrefthat people are waiting for. The alternative is to leavecall_indirectandfunc.refas is. It is just possible that this means that, depending on the solution we arrive at,externrefmight not be instantiable like a true type import would be, and/or thatexternrefmight (ironically) not be able to have any supertypes (e.g. might not be able to be a subtype ofanyrefif we do eventually decide to addanyref).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_indirectversus Abstraction, in DetailI'll use the notation
call_indirect[ti*->to*](func, args), where[ti*] -> [to*]is the expected signature of the function,funcis simply a funcref (rather that a funcref table and an index), andargsare theto*values to pass to the function. Similarly, I'll usecall($foo, args)for a direct call of the function with index$foopassing argumentsargs.Now suppose
$foois the index of a function with declared input typesti*and output typesto*. You might expect thatcall_indirect[ti*->to*](ref.func($foo), args)is equivalent tocall($foo, args). Indeed, that is the case right now. But it's not clear that we can maintain that behavior.call_indirectand SubtypingOne example potential problem came up in the discussion of subtyping. Suppose the following:
tsubis a subtype oftsuper$fsubthat was defined with type[] -> [tsub]$fsuperwith type[] -> [tsuper]$fsubas$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:If we were to choose outcome 1, realize that we would likely need to employ one of two techniques to make this possible:
call_indirectcompare with the import signature rather than 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 aref ([] -> [tsub])and also aref ([] -> [tsuper]), and yet technique 1 will not be sufficient to keepcall_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_indirectin IB is equal to the signature of the definition of$fsubin 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
$fsubto have type[] -> [tsub], instance IA only exports it with type[] -> [tsuper]. Using the technique for outcome 2, instance IB can (maliciously) executecall_indirect[ -> tsub]($fsuper)and the call will succeed. That is, IB can usecall_indirectto 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_indirectand Type ImportsNow 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
capabilityand exports the type but not its definition as$handlemodule instance IC exports a function
$do_stuffthat was defined with type[capability] -> []but exported with type[$handle] -> []module MD imports a type
$externand a function$runwith type[$extern] -> []module instance ID is module MD instantiated with IA's exported
$handleas$externand with IA's exported$do_stuffas$runWhat 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
eof type$externand executescall_indirect[$extern -> ](ref.func($run), e). Here are the two outcomes that seem most plausible:Outcome 2 makes
call_indirectpretty much useless with imported types. So for outcome 1, realize that the input type$externis not the defined input type of$do_stuff(which instead iscapability), so we would likely need to use one of two techniques to bridge this gap:call_indirectcompare with the import signature rather than the definition signature.$externin 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
$handlethat IC had kept secret. Suppose further that ID has a good guess as to what$handlereally represents, namelycapability. ID can define the identity function$id_capabilityof type[capability] -> [capability]. Given a valueeof type$extern, ID can then executecall_indirect[$extern -> capability](ref.func($id_capability), e). Using technique 2, this indirect call will succeed because$externrepresentscapabilityat run time, and ID will get the rawcapabilitythaterepresents back. Similarly, given a valuecof typecapability, ID can executecall_indirect[capability -> $extern](ref.func($id_capability), c)to forgecinto an$extern.Conclusion
Hopefully I've made it clear that
call_indirecthas 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 tocall_indirectbeing 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 restrictcall_indirectandfunc.refto only numeric types for now—a restriction we might be able to relax depending on how we eventually end up solving the overarching issues withcall_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.)