← Back to Writeups
HTBN/AReversing

Coffee Invocation

XESXOR8/23/20267 min read
#reversing#htb#n/a

Coffee Invocation

Platform: HackTheBox | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-21 | Status: Solved Techniques: jni_reverse_engineering, java_bytecode_disassembly, class_file_carving, lookup_table_inversion, boolean_swap_detection

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: hackthebox | ID: 20260521_hackthebox_coffee_invocation
  • Tags: reverse_engineering, java, elf, bytecode, lookup_table, jni, jvm_internals
  • Indicators: JNI_CreateJavaVM import in ELF binary, 0xCAFEBABE magic bytes embedded in non-Java binary, Byte/Short/Boolean cache mutation via native code, System.exit used as progress channel not termination, Boxed primitive singleton swap inverts boolean logic
  • Source: 20260521_hackthebox_coffee_invocation.md

Foothold

Vulnerability / Misconfiguration

  1. Jni_reverse_engineering
  2. Java_bytecode_disassembly
  3. Class_file_carving
  4. Lookup_table_inversion
  5. Boolean_swap_detection
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • jni_reverse_engineering
  • java_bytecode_disassembly
  • class_file_carving
  • lookup_table_inversion
  • boolean_swap_detection
  • Tags: reverse_engineering, java, elf, bytecode, lookup_table, jni, jvm_internals

Original Writeup

<details><summary>Click to expand original content</summary>

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:

REDACTED

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

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR