reversefreemedium

Coffee Invocation

hackthebox

Task: Stripped ELF64 binary that boots a JVM via JNI, loads embedded Java classes, and mutates boxed primitive caches to validate a flag. Solution: Carved Java classes from ELF, reversed native lookup tables and character remaps, detected Boolean.TRUE/FALSE swap to bypass Tinfoil decoy.

$ ls tags/ techniques/
jni_reverse_engineeringjava_bytecode_disassemblyclass_file_carvinglookup_table_inversionboolean_swap_detection

Coffee Invocation — Hack The Box

Description

Our new crazy conspiracy theorist intern, has blocked everyone from the coffee machine because he saw that aliens were trying to steal the "out of the world" secret recipe. Your mission is to unveil the secrets that lie behind his profound madness and teach him a javaluable lesson.

This is a native reverse-engineering challenge with embedded Java, not a web challenge and not a vulnerable Java application. The binary is a stripped ELF64 PIE that boots a JVM through JNI, loads hidden class files from its own image, sabotages Java runtime internals, and uses the modified semantics to validate the flag.

Analysis

The main hints are already in the title and strings:

  • Coffee and javaluable strongly suggest Java.
  • The ELF imports JNI_CreateJavaVM, which confirms native-to-Java embedding.
  • Strings reference Verify1, Verify2, staged verification messages, and the HTB{ flag prefix.

So the correct mental model is:

  1. reverse the native ELF,
  2. recover the embedded Java classes,
  3. understand how JNI modifies Java behavior,
  4. invert both verification stages.

Extracting the hidden Java classes

binwalk would normally be a quick way to scan for embedded artifacts, but it was broken in this environment due to a Python imp import issue. Instead, I carved the classes directly by searching for the Java class magic 0xCAFEBABE.

The helper script extract_classes.py parses class-file structure so it can recover each full blob cleanly instead of dumping arbitrary bytes.

Relevant offsets found inside the ELF:

  • 0x5180Verify1.class
  • 0x5680Verify2.class

The extraction logic is straightforward:

sig = b"\xca\xfe\xba\xbe" while True: i = data.find(sig, i) if i < 0: break end = parse_class_end(data, i) chunk = data[i:end]

After carving the files, javap -c -p gives enough bytecode to reconstruct the validation logic.

What the Java code does

Verify1

Verify1.main() takes two strings, checks that they have the same length, and then compares them character by character using:

compareByte(Byte.valueOf(source[i]), Short.valueOf(target[i]))

At first glance that looks pointless, because a Byte and a Short should compare equal only if their numeric values match. But the native code has already tampered with Java's boxed primitive internals and caches, so those wrapper objects no longer behave normally.

The hardcoded target string is:

~PL{A;PL{?;:=|PIC{HzP:A;~x

This stage validates the first 26 bytes of the password.

Verify2

Verify2.main() takes one string and processes it two characters at a time.

For each pair it:

  1. extracts a 2-character substring,
  2. runs it through complexSort(pair, Boolean.TRUE),
  3. compares the result with a 2-character slice from a transformed version of the constant string:
Cr1KD5mk0_uUzQYifaGVqlN2B3wvpgPtSx6Odo{8hjJLHy9IXb4RnWZ}TAFEsMce7

At the end it also checks whether the whole input equals "Tinfoil".

That final check is a trap: it is not the real password. Native code swaps Boolean.TRUE and Boolean.FALSE, so Java boolean-based logic no longer means what the decompiled source suggests.

JNI/native trickery

The real difficulty is in the ELF, not in the Java bytecode.

The native loader does all of the following before running the Java classes:

  • hooks java/lang/Shutdown.halt0 so the program can observe Java exit codes without terminating immediately,
  • loads the carved classes through DefineClass,
  • mutates Java Byte and Short cache/object behavior,
  • mutates Java Character internals using remap tables,
  • swaps Boolean.TRUE and Boolean.FALSE.

This means the Java code must be read as logic running on a hostile JVM state, not on a normal one.

Exit-code staging

Verify2 uses System.exit(i + 3) after each successful pair. The native hook captures those exit codes and uses them as a progress channel. When the expected sequence is reached, native state advances until the final stage succeeds.

So the staged exits are not failure paths; they are part of the intended verification protocol.

Solution

Reversing stage 1 (Verify1)

Native code uses two 256-byte lookup tables located at virtual addresses:

  • 0x74c0
  • 0x75e0

Once Byte and Short are mutated, the comparison effectively becomes:

byte_map[source_char] == short_map[target_char]

Since the target string is known, we can invert the first map and recover the source bytes directly:

byte_map = rd.read_va(0x74C0, 256) short_map = rd.read_va(0x75E0, 256) inv_byte_map = {value: idx for idx, value in enumerate(byte_map)} part1 = bytes(inv_byte_map[short_map[ord(ch)]] for ch in TARGET1)

This reconstructs the first 26 characters of the password.

Reversing stage 2 (Verify2)

This stage is more subtle.

Native code stores pointers to 13 successive 127-byte character remap tables at virtual address 0x85c0. Each pair of password characters is verified under a different remapping.

For pair index i:

  1. the candidate pair is remapped through table i,
  2. the long target string is remapped through the same table,
  3. the remapped target is sorted,
  4. bytes 2*i : 2*i+2 of that sorted string become the expected output for the remapped pair.

So each pair can be solved independently by inverting the corresponding character map.

table_ptrs = [struct.unpack("<Q", rd.read_va(0x85C0 + i * 8, 8))[0] for i in range(13)] for i, ptr in enumerate(table_ptrs): char_map = rd.read_va(ptr, 127) inv_char_map = {value: idx for idx, value in enumerate(char_map)} mapped_target = "".join(chr(char_map[ord(ch)]) for ch in TARGET2) expected_pair = "".join(sorted(mapped_target))[2 * i:2 * i + 2] part2.extend(inv_char_map[ord(ch)] for ch in expected_pair)

That yields the remaining 26 characters.

Why Tinfoil is a decoy

The decompiled Java suggests the final password should be Tinfoil:

if (!verifyPassword(input, "Tinfoil").booleanValue()) { System.exit(2); }

Normally that would reject every string except Tinfoil. But native code swaps the singleton objects behind Boolean.TRUE and Boolean.FALSE, which inverts the meaning of the boxed result returned by:

Boolean.valueOf(source.equals(target))

As a result:

  • input == "Tinfoil" produces the wrong logical outcome,
  • input != "Tinfoil" is what the success path actually expects.

So Tinfoil is there to mislead anyone who only decompiles the Java and ignores the native runtime tampering.

Recovering the real password and flag

Combining both recovered halves gives the 52-byte password:

1_c4nt_c4ptur3_fl4g5_unt17_1v3_h4d_a1l_my_0xCAFEBABE

The binary then prints that password as the flag suffix in the format HTB{<suffix>}.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups