toc
vq-blog about

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.

Static analysis

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.

Dynamic analysis

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.

Fixing clambc

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.

Modifying clambc

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.

Solution

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}