Variadic functions are functions which accept different number of arguments depending on the needs of the caller. Typical examples include
scanf in C and C++ but there are other functions, or even some custom ones (specific to the binary being analyzed). Because each call of a variadic function may have a different set of arguments, they need special handling in the decompiler. In many cases the decompiler detects such functions and their arguments automatically but there may be situations where user intervention is required.
Changing the function prototype
For standard variadic functions IDA usually applies the prototype from a type library but if there’s a non-standard function or IDA did not detect that a function is variadic, you can do it manually. For example, a decompiled prototype of unrecognized variadic function on ARM64 may look like this:
void __fastcall logfunc( const char *a1, __int64 a2, __int64 a3, __int64 a4, __int64 a5, __int64 a6, __int64 a7, __int64 a8, char a9)
But when you inspect the call sites, you see that most of the passed arguments are marked as possibly uninitialized (orange color):
The first argument looks like a format string so the rest are likely variadic. So we can try to change the prototype to:
void logfunc(const char *, ...);
which results in clean decompilation:
Adjusting variadic arguments
With correct prototypes, decompiler usually can guess the actual arguments passed to each invocation of the function. However, in some cases the autodetection can misfire, especially if the function uses non-standard format specifiers or does not use a format string at all. In such case, you can adjust the actual number of arguments being passed to the call. This can be done via the context menu commands “Add variadic argument” and “Delete variadic argument”, or the corresponding shortcuts Numpad + and Numpad -.
Variadic calls and tail branch optimization
In some rare situations you may run into the following issue: when trying to add or remove variadic arguments, the decompiler seems to ignore the action. This may occur in functions subjected to a specific optimization. For example, here’s pseudocode of a function which seems to have two calls to a logging function:
The decompiler has decided that
a3 is also passed to the calls, however we can see that the format strings do not have any format specifiers so a3 is a false positive and should be removed. However, using “Delete variadic argument” on the first call seems to have no effect. What’s happening?
This is one of the rare cases where switching to disassembly can clear things up. By pressing Tab, we can see a curious picture in the disassembly: there is only one call!
This is an example of so-called tail branch merging optimization, where the same function call is reused with different arguments. For better code readability, the decompiler detects this situation and creates a duplicate call statement with the second set of arguments. Because the information about the number of variadic arguments is attached to the actual call instruction, it can’t be changed for the “fake” call inserted by the decompiler. You can change it for the “canonical” one which can be discovered by pressing Tab on the call (
BL instruction). Removing the argument there affects both calls in the pseudocode.
If you’re curious to see the “original” code, it can be done by turning off “Un-merge tail branch optimization” in the decompiler’s Analysis Options 1.
With it off, there is only one call just like in the disassembly, at the cost of an extra
goto and some local variables: