We are provided with just two files print_flag.cbc
and run.sh
. The former contains ascii printable gibberish (see below).
ClamBCafhmlmengff|aa```c``a```|ah`cnbac`cecnb`c``biaabp`clamcoincidencejb:4096
PRINT_FLAG.{Fake,Real};Engine:56-255,Target:0;0;0:4d5a
Teddaaahdabahdacahdadahdaeahdafahdagahebheebgeebfe
Eaeacabbbe|aebgefafdf``adbce|aecgefefkf``aebbe|am
G`am`@`bheBlbAeBbmBhoAdAcBdcAfBnoBknAfB`lB`lBioAbB
A`b`bLbnab`dab`dabadab`eabad`b`b`aa`b`b`b`b`b`b`b`b`aa`b`d`b`d`b`d`b`b`bad`bad`ah`b`d`b`d`b`b`bad`bad`ah`aa`b`d`b`b`b`d`b`b`Fbhbag
Bbadaddbbaeac@db`baeabbad@dAbd``hbad@aC``ddaaafeab`baeClhadTaaafabaa
Bb`bagabbaeAl`Aodb`d`bbAah`Tbaaf
While the latter (run.sh
) is set up to start a dockercontainer containing ClamAV and use the print_flag.cbc
as custom bytecode to be run in the scanning process. Since the sample.exe
was not contained in the package, i provided a random executable from my windows VM as input.
#!/bin/sh
docker run -v /home/pleb/hitcon/:/test/ --rm -it clamav/clamav clamscan --bytecode-unsigned -d/test/print_flag.cbc /test/sample.exe
To gain more control over the execution environment i just installed ClamAV locally. When running the command (with adjusted paths) it prints PRINT_FLAG.Fake
for my random executable. Thus the task at hand is likely to craft an executable that prints PRINT_FLAG.Real
.
After a few google searches, i stumbled over documentation for clambc
which allows running the bytecode directly. This executable also contains functionalities to dump the bytecode into a readable format and gather meta-information. Running clambc print_flag.cbc --info
yields:
Bytecode format functionality level: 6
Bytecode metadata:
compiler version: 0.105.0
compiled on: (1719557581) Fri Jun 28 08:53:01 2024
compiled by:
target exclude: 0
bytecode type: logical only
bytecode functionality level: 0 - 0
bytecode logical signature: PRINT_FLAG.{Fake,Real};Engine:56-255,Target:0;0;0:4d5a
virusname prefix: (null)
virusnames: 0
bytecode triggered on: files matching logical signature
number of functions: 2
number of types: 25
number of global constants: 13
number of debug nodes: 0
bytecode APIs used:
read, seek, setvirusname
While this is not terribly helpful, it contains some bits of useful information. Especially that the bytecode only uses three apis: read
, seek
and setvirusname
. The latter is likely used to print the PRINT_FLAG.{Fake,Real}
messages. From the names of the other two functions read
and seek
i just assume that those were the bytecode equivalents of the standard read
and seek
implementations used by libc. This bit of conjecture will become important later. Furthermore clambc
allows to dump the “disassembled” bytecode with clambc --printbcir print_flag.cbc
.
########################################################################
####################### Function id 0 ################################
########################################################################
found a total of 13 globals
GID ID VALUE
------------------------------------------------------------------------
0 [ 0]: i0 unknown
[...]
12 [ 12]: i8* unknown
------------------------------------------------------------------------
found 30 values with 0 arguments and 30 locals
VID ID VALUE
------------------------------------------------------------------------
0 [ 0]: alloc i64
1 [ 1]: alloc i64
2 [ 2]: alloc i8*
3 [ 3]: alloc [1024 x i8]
4 [ 4]: i8*
5 [ 5]: i32
6 [ 6]: i1
[...]
29 [ 29]: i32
------------------------------------------------------------------------
found a total of 23 constants
CID ID VALUE
------------------------------------------------------------------------
0 [ 30]: 0(0x0)
1 [ 31]: 0(0x0)
2 [ 32]: 2(0x2)
3 [ 33]: 0(0x0)
4 [ 34]: 1024(0x400)
5 [ 35]: 396(0x18c)
[...]
22 [ 52]: 1(0x1)
------------------------------------------------------------------------
found a total of 53 total values
------------------------------------------------------------------------
FUNCTION ID: F.0 -> NUMINSTS 40
BB IDX OPCODE [ID /IID/MOD] INST
------------------------------------------------------------------------
0 0 OP_BC_GEPZ [36 /184/ 4] 4 = gepz p.3 + (30)
0 1 OP_BC_CALL_API [33 /168/ 3] 5 = seek[3] (31, 32)
0 2 OP_BC_MEMSET [40 /200/ 0] 0 = memset (p.4, 33, 34)
0 3 OP_BC_ICMP_EQ [21 /108/ 3] 6 = (5 == 35)
0 4 OP_BC_BRANCH [17 / 85/ 0] br 6 ? bb.2 : bb.1
1 5 OP_BC_CALL_API [33 /168/ 3] 7 = setvirusname[4] (p.-2147483636, 36)
1 6 OP_BC_COPY [34 /174/ 4] cp 37 -> 0
1 7 OP_BC_JMP [18 / 90/ 0] jmp bb.6
2 8 OP_BC_CALL_API [33 /168/ 3] 8 = seek[3] (38, 39)
2 9 OP_BC_CALL_API [33 /168/ 3] 9 = read[1] (p.4, 40)
2 10 OP_BC_CALL_DIRECT [32 /163/ 3] 10 = call F.1 (4, 41)
2 11 OP_BC_COPY [34 /174/ 4] cp 42 -> 1
2 12 OP_BC_JMP [18 / 90/ 0] jmp bb.4
3 13 OP_BC_ICMP_ULT [25 /129/ 4] 11 = (26 < 43)
3 14 OP_BC_COPY [34 /174/ 4] cp 26 -> 1
3 15 OP_BC_BRANCH [17 / 85/ 0] br 11 ? bb.4 : bb.5
4 16 OP_BC_COPY [34 /174/ 4] cp 1 -> 12
[some math]
4 30 OP_BC_ICMP_EQ [21 /106/ 1] 25 = (18 == 24)
4 31 OP_BC_ADD [1 / 9/ 0] 26 = 12 + 49
4 32 OP_BC_COPY [34 /174/ 4] cp 50 -> 0
4 33 OP_BC_BRANCH [17 / 85/ 0] br 25 ? bb.3 : bb.6
5 34 OP_BC_CALL_API [33 /168/ 3] 27 = setvirusname[4] (p.-2147483638, 51)
5 35 OP_BC_COPY [34 /174/ 4] cp 52 -> 0
5 36 OP_BC_JMP [18 / 90/ 0] jmp bb.6
6 37 OP_BC_COPY [34 /174/ 4] cp 0 -> 28
6 38 OP_BC_TRUNC [14 / 73/ 3] 29 = 28 trunc ffffffffffffffff
6 39 OP_BC_RET [19 / 98/ 3] ret 29
Note that the real output is way longer as it contains two functions. I also removed some on the variables that were not necessary for the oncoming explanation. The function shown in the snippet is F.0
, F.1
was cut out completely and is a fair bit more complex!
So lets start dissecting the bytecode of F.0
(lowest section of the listing above). The first index in the “BB” row is used for the “BasicBlock”, while the “IDX” row is the index of the instruction. I will use the latter to refer to specific instructions.
Lets start with IDX 1. This line calls seek
with 31
and 32
and stores the result in 5
. The syntax is a bit odd, and it took me a bit to realize that those numbers were the IDs of the variables and constants printed above! Note that the “ID” is in the second row of the tables above, not to be confused with the first row, which contains non-unique identifiers. Thus looking for IDs 31
and 32
we see that the call is actuall seek(0,2)
and 2 is the constant for SEEK_END
. Thus the result stored in 5
is the file length! On instruction 3, this result is compared with 35
which corresponds to 0x18C
. Instructions 4 branches based on the result of this comparison. If the comparison yields false
, a jump to bb.1
(see the BB id in the left most row) is taken, which calls setvirusname and then continues to jump to bb.6
which exits the bytecode. A comparison yielding true
leads us to bb.2
which calls F.1
and then continues to check different things. At this point it seemed very likely, that the second setvirusname
call would be for PRINT_FLAG.Real
. Thus reaching IDX 34 was would be the goal. For this to happen, the OP_BC_ICMP_EQ
at IDX 30 would have to be passed.
To confirm my static analysis, i played around a bit and indeed a file with a length != 0x18c would result in PRINT_FLAG.Fake
being printed while a file of length 0x18c would not result in any PRINT_FLAG
output at all. Note that i just cut the test executable to the appropriate size, it would not have been executable at all at this point. If you are following along and don’t have a PE file ready, be sure to set the appropriate “DOS” magic MZ
as the first two bytes, otherwise the bytecode will not be called on the file.
At this point it would have been possible to reverse engineer the bytecode statically and translate it into python. However since the second function was way more complex i opted to not do that at it would likely have cost me the whole weekend.
I was in luck, because clambc
also allows the execution of bytecode!
clambc print_flag.cbc --input test.exe
Segmentation fault (core dumped)
I don’t know what i expected. Trying to run the bytecode resulted in a segfault almost immediately. This was a bit confusing to me, since the clamscan
command in the run.sh
file executed successfully. At first i thought it was some clever edge case in the byte code that the challenge author abused to make the bytecode VM crash.
In a desperate attempt to see if the most recent version of ClamAV had this issue fixed, i downloaded the code and built the whole thing from source. For future reference the line numbers mentioned here are referring to version 1.3.1 of ClamAV.
However despite the bleeding egde ClamAV binaries, the crash persisted. To evaluate where it was crashing, i ran the executable in gdb
. Note that i built ClamAV in debug mode, and thus all debug information was present. This conveniently pointed me to the right C-source file immediately upon the segfault. The file in question is clambc/bcrun.c
.
int main(int argc, char *argv[])
{
[...]
cli_ctx cctx; // line 369
struct cl_engine *engine = cl_engine_new();
fmap_t *map = NULL; // <- map is set to 0 line 371
memset(&cctx, 0, sizeof(cctx));
[...]
// ctx was memset, so recursion_level starts at 0.
cctx.recursion_stack[cctx.recursion_level].fmap = map; // line 411
cctx.recursion_stack[cctx.recursion_level].type = CL_TYPE_ANY; /* ANY for the top level, because we don't yet know the type. */
cctx.recursion_stack[cctx.recursion_level].size = map->len; // line 413
This snippet only shows an excerpt from the main function. The comments contain the original line numbers for some lines. Consider line 371 where the variable map
is assigned to be NULL
(which just translates to 0
). Now line 413 accesses the member variable map->len
. Anyone familiar with C/C++ will immediatly see the problem. The ->
operator perfoms something similar to (*map).len
. This is a pointer dereference. Dereferencing a null-pointer will instantly segfault an application. Changing line 413 to cctx.recursion_stack[cctx.recursion_level].size = 0
fixed the segfault. Is it a proper fix for all circumstances? I have no clue, but it worked for all my intents and purposes, thus it was good enough.
Now it was possible to run the bytecode with clambc
and provide different executables as input!
While clambc
did not provide a detailed execution trace, it revealed the number of executed instructions when adding the --debug
flag.
[...]
LibClamAV debug: interpreter bytecode run finished in 4522108us, after executing 413569 opcodes
[...]
Realizing that there was an instruction counting mechanism built in, i though i could utilize that to bruteforce the file byte by byte. However for some reason that did not yield the wanted results, as a null byte almost always resulted in a higher instruction count for some reason. So i had to look for an alternative solution.
In the help output printed by clambc
the tracing level can be adjusted between 0 and 7, however this setting does not seem to have any effect. I searched the sourcecode for any mentions of the tracing capabilities and stumbled upon the following code snippet.
#if 0 /* too verbose, use #ifdef CL_DEBUG if needed */
#define CHECK_UNREACHABLE \
do { \
cli_dbgmsg("bytecode: unreachable executed!\n"); \
return CL_EBYTECODE; \
} while (0)
#define TRACE_PTR(ptr, s) cli_dbgmsg("bytecode trace: ptr %llx, +%x\n", ptr, s);
#define TRACE_R(x) cli_dbgmsg("bytecode trace: %u, read %llx\n", pc, (long long)x);
#define TRACE_W(x, w, p) cli_dbgmsg("bytecode trace: %u, write%d @%u %llx\n", pc, p, w, (long long)(x));
#define TRACE_EXEC(id, dest, ty, stack) cli_dbgmsg("bytecode trace: executing %d, -> %u (%u); %u\n", id, dest, ty, stack)
#define TRACE_INST(inst) \
do { \
unsigned bbnum = 0; \
printf("LibClamAV debug: bytecode trace: executing instruction "); \
cli_byteinst_describe(inst, &bbnum); \
printf("\n"); \
} while (0)
#define TRACE_API(s, dest, ty, stack) cli_dbgmsg("bytecode trace: executing %s, -> %u (%u); %u\n", s, dest, ty, stack)
#else
#define CHECK_UNREACHABLE return CL_EBYTECODE
#define TRACE_PTR(ptr, s)
#define TRACE_R(x)
#define TRACE_W(x, w, p)
#define TRACE_EXEC(id, dest, ty, stack)
#define TRACE_INST(inst)
#define TRACE_API(s, dest, ty, stack)
#endif
Despite my blog being thoroughly confused how syntax highlighting in preprocessor macros works, one can see that all TRACE_
macros in the else branch are just blank. The actual tracing was hidden behind an #if 0
. Just flipping that 0
to a 1
and recompiling provided me with a VERY detailed execution trace like this:
LibClamAV debug: bytecode trace: executing instruction OP_BC_ICMP_EQ [21 /106/ 1] 1185 = (1136 == 1184)
LibClamAV debug: bytecode trace: 413563, read 45
LibClamAV debug: bytecode trace: 413563, read 0
LibClamAV debug: bytecode trace: 413563, write8 @1185 0
LibClamAV debug: bytecode trace: executing instruction OP_BC_ADD [1 / 9/ 0] 1192 = 1088 + 1376
LibClamAV debug: bytecode trace: 413564, read 5
LibClamAV debug: bytecode trace: 413564, read 1
LibClamAV debug: bytecode trace: 413564, write64 @1192 6
LibClamAV debug: bytecode trace: executing instruction OP_BC_COPY [34 /174/ 4] cp 1384 -> 0
LibClamAV debug: bytecode trace: 413565, read 0
LibClamAV debug: bytecode trace: 413565, write64 @0 0
LibClamAV debug: bytecode trace: executing instruction OP_BC_BRANCH [17 / 85/ 0] br 1185 ? bb.3 : bb.6
Despite the differing variable indices, the OP_BC_ICMP_EQ
shown in this excerpt corresponds with the one at IDX 30 shown in the listing of the F.0
source code above. This is the check we need to pass. As we can see in this snipped, 45
gets compared to 0
, this comparison yields false and subsequently a jump to bb.6
is taken to return from the function. Since i now had the possibility to dynamically inspect the data, i saw that a bytewise bruteforce would be possible. However there was a lot of printing to stdout, which slowed down the bytecode execution to a horrifying degree (the comment in the source code was telling the truth about tracing verbosity!). By my rough estimations, bruteforcing this way would have easily taken 8 hours.
As a quick sidenote: clambc
was logging to both stdout and stderr. When trying to redirect this to a file it resulted in buffered writes, grouping stderr and stdout together instead of the regular chronologic series of events. Thus i also added setvbuf(stdout, NULL, _IONBF, 0)
and setvbuf(stderr, NULL, _IONBF, 0);
to the main
function in bcrun.c and hence disabled the buffered writes.
To cut down on execution time, i switched the if 1
back to an if 0
and added the following to the empty TRACE_INST
macro.
#define TRACE_INST(inst) \
do { \
if(inst->opcode == OP_BC_ICMP_EQ) { \
unsigned bbnum = 0; \
printf("LibClamAV debug: bytecode trace: executing instruction "); \
cli_byteinst_describe(inst, &bbnum); \
printf("\n"); \
} \
} while (0)
In short, i enabled tracing only for the OP_BC_ICMP_EQ
instructions in the bytecode. Thus i could look for the count of “[21 /106/ 1] 1185 = (1136 == 1184)” comparisons in the output and evaluate if my input byte successfully passed the check! To further cut execution time, i also recompiled the clambc
binary in release mode to take advantage of the optimizations being done by the compiler.
The final solution script revolves aroun the idea described above. I create a file with an MZ
at the start, then i modify the next byte until i pass the mentioned comparison directly and the identified, correct byte gets added to the file_contents
. Note that the file is always padded to a length of 0x18c by appending 0xff bytes. This slowly bruteforces the whole PE executable. The flag extraction is then just a matter of running it, or reimplementing the logic in python.
import subprocess
file_contents = b'MZ'
if __name__ == "__main__":
log = open("log.txt", "w")
for file_index in range(len(file_contents), 0x18D):
max_compcount = file_index + 2
next_char = -1
for i in range(0,0x100):
buf = file_contents + i.to_bytes(1,'little')
buf = buf.ljust(0x18c, b"\xff")
f = open("test.exe","wb")
f.write(buf)
f.close()
res = subprocess.run(['./clambc', '/mnt/hgfs/Stuff/CTFs/2024/hitcon/antivirus/print_flag.cbc', '-T0', '--debug', '--input', './test.exe', '-s', '-f'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout
comp_count = len(res.split(b"[21 /106/ 1] 1185 = (1136 == 1184)"))
log.write(f"{comp_count}\n")
log.flush()
if comp_count >= file_index +3:
next_char = i.to_bytes(1, "little")
break
assert next_char != -1
log.write(f"byte: {next_char} won with {comp_count} comparisons\n")
file_contents += next_char
log.write(f"{file_contents}\n")
log.write(f"{len(file_contents)}\n")
log.flush()
The flag: hitcon{secret_unpacker_in_clamav_bytecode_signature}