Reversing V8 Bytecode: Format, Version Fingerprinting, and Disassembly

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

JavaScript keeps eating the world, and with that comes a growing demand for protecting JS source. But JavaScript is interpreted — the interpreter needs the code itself present — so it’s fundamentally easier to reverse than compiled languages. This post is a practical deep dive into the V8 bytecode format: how to build V8, parse the serialized format, brute-force the engine version from a hash, and force V8’s own disassembler to dump bytecode. The decompilation half is still a work in progress; this covers the disassembly side.

Why protect JS at all?

Say you have a core validation function:

function validate(license) {
    if (license === "lol_you_will_never_know") {
        console.log("valid")
        return true
    }
    console.log("not valid")
    return false;
}

module.exports = { validate };

Called from another file:

const core = require("./core.js");

console.log(core.validate("aaaaa"));

No matter how fancy validate gets, if you ship core.js with the app, it’s plaintext to the user. Anyone can open it and read the validation logic — especially painful for Electron apps and client-side checks.

Bytenode

The increasingly popular fix is to compile your core JS to V8 bytecode and load it through vm, so the raw source is gone. Bytenode does exactly this: it turns .js into .jsc, which you load with Node.js/Electron’s vm module.

pnpm install bytenode
bytenode --compile core.js

That produces core.jsc, which you can’t require() directly — so you add a loader:

require("bytenode");
const core = require("./core.jsc");

console.log(core.validate("aaaaa"));

Run it and you get the same output — but core.jsc now contains raw bytes, not source:

03:57:21 $ xxd core.jsc
00000000: 8806 dec0 1477 2c2b 1701 0000 9b61 7c1c  .....w,+.....a|.
00000010: 4243 1cd3 b803 0000 0000 0000 0000 0000  BC..............
00000020: 0124 5403 2407 b460 0000 0000 0600 0000  .$T.$..`........
00000030: 0108 07bd 0e04 0421 030c 0785 0161 0000  .......!.....a..
00000040: 0000 0700 0000 0104 0200 0adc 0800 2107  ..............!.
00000050: 4111 2103 0c07 7d01 6000 0000 0001 0000  A.!...}.`.......
00000060: 0001 2454 032c 9060 0000 0000 1800 0000  ..$T.,.`........
00000070: 0108 9104 1821 0310 9362 0000 0000 0d00  .....!...b......
00000080: 0000 012c 0300 0aac 070f 4c0d 0f0a 4000  ...,......L...@.
00000090: 0000 2194 2103 1895 6000 0000 0004 0000  ..!.!...`.......

That byte stream is V8 bytecode.

The V8 engine

V8 is Google’s high-performance JavaScript engine. It’s a heap-based world where the JIT (Ignition for bytecode, TurboFan for optimized code, Orinoco for GC) turns scripts into machine code. V8 bytecode is what the Ignition interpreter reads — fundamentally, it’s data that V8 serializes.

Since .jsc is literally V8 bytecode, the first step is being able to build V8 so we can analyze and debug it. The build is standard (see Building V8 from source):

cd your_working_dir
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$PWD/depot_tools:$PATH"

fetch v8
cd v8

gn gen out/node.x64.release --args='is_debug=false v8_enable_disassembler=true v8_enable_object_print=true v8_enable_pointer_compression=false'

# compile may takes years if your computer sucks
ninja -C out/debug d8

Matching the V8 version matters

V8 bytecode’s internal structure is not stable. Across versions:

  • the opcode set can differ
  • an opcode’s number and argument meaning can differ
  • the register-allocation layout changes with optimizer changes

So bytecode compiled by one version won’t match another — sometimes even a minor bump changes the layout. To switch versions:

git checkout <version_tag>
gclient sync -D
git tag -l | grep ^11.
# 11.8.172.13

Then rebuild with gn gen ... + ninja. To see which V8 your Node.js uses:

00:01:14 $ node -p process.versions.v8
12.4.254.21-node.26

To find the V8 for other Node.js versions, check the Node.js release page’s details for each version:

Node.js release list showing the bundled V8 version

Build-arg gotchas

The bytecode layout also depends on the build args (--args=...). If your build args don’t match the ones that produced the bytecode, the structure won’t line up and disassembly fails. Key points:

  • v8_enable_pointer_compression — controls pointer compression. Defaults to true. Node.js builds with false (see common.gypi); Electron needs true.
  • is_debug — don’t use debug for real decompilation. Debug adds extra fields, padding, and debug info. Node/Electron ship release builds, so we should too.
  • v8_enable_disassembler, v8_enable_object_print — just enable them.

Note that Node.js and Electron both patch V8 (and Electron patches Node), so their bytecode differs from vanilla V8. For a 1:1 Node.js environment, apply the nodejs/node patches yourself; for Electron, apply the Electron patches first.

The V8 bytecode structure

For this walkthrough I use Node v24.7.0, which is V8 13.6.233.10-node.26. Bytecode structure differs across versions.

V8 bytecode is produced by CodeSerializer::Serialize when a JS script is compiled. That function packs the compiled BytecodeArray and its dependencies (constant pool, object literals, jump tables, source info) into one continuous binary stream:

CodeSerializer::Serialize in code-serializer.cc

That stream is wrapped in an AlignedCachedData, which uses SerializedCodeData to produce the final bytes:

// 'src/snapshot/code-serializer.cc'
AlignedCachedData* CodeSerializer::SerializeSharedFunctionInfo(
    Handle<SharedFunctionInfo> info) {
  DisallowGarbageCollection no_gc;

  VisitRootPointer(Root::kHandleScope, nullptr,
                   FullObjectSlot(info.location()));
  SerializeDeferredObjects();
  Pad();

  SerializedCodeData data(sink_.data(), this);

  return data.GetScriptData();
}

The SerializedCodeData header

SerializedCodeData is the outermost structure, in src/snapshot/code-serializer.h:

// 'src/snapshot/code-serializer.h'
class SerializedCodeData : public SerializedData {
 public:
  // The data header consists of uint32_t-sized entries:
  static const uint32_t kVersionHashOffset = kMagicNumberOffset + kUInt32Size;
  static const uint32_t kSourceHashOffset = kVersionHashOffset + kUInt32Size;
  static const uint32_t kFlagHashOffset = kSourceHashOffset + kUInt32Size;
  static const uint32_t kReadOnlySnapshotChecksumOffset =
      kFlagHashOffset + kUInt32Size;
  static const uint32_t kPayloadLengthOffset =
      kReadOnlySnapshotChecksumOffset + kUInt32Size;
  static const uint32_t kChecksumOffset = kPayloadLengthOffset + kUInt32Size;
  static const uint32_t kUnalignedHeaderSize = kChecksumOffset + kUInt32Size;
  static const uint32_t kHeaderSize = POINTER_SIZE_ALIGN(kUnalignedHeaderSize);
//
// some code ignored
// ...
}

And the constructor that writes it:

// 'src/snapshot/code-serializer.cc'
SerializedCodeData::SerializedCodeData(const std::vector<uint8_t>* payload,
                                       const CodeSerializer* cs) {
  DisallowGarbageCollection no_gc;

  // Calculate sizes.
  uint32_t size = kHeaderSize + static_cast<uint32_t>(payload->size());
  DCHECK(IsAligned(size, kPointerAlignment));

  // Allocate backing store and create result data.
  AllocateData(size);

  // Zero out pre-payload data. Part of that is only used for padding.
  memset(data_, 0, kHeaderSize);

  // Set header values.
  SetMagicNumber();
  SetHeaderValue(kVersionHashOffset, Version::Hash());
  SetHeaderValue(kSourceHashOffset, cs->source_hash());
  SetHeaderValue(kFlagHashOffset, FlagList::Hash());
  SetHeaderValue(kReadOnlySnapshotChecksumOffset,
                 Snapshot::ExtractReadOnlySnapshotChecksum(
                     cs->isolate()->snapshot_blob()));
  SetHeaderValue(kPayloadLengthOffset, static_cast<uint32_t>(payload->size()));

  // Zero out any padding in the header.
  memset(data_ + kUnalignedHeaderSize, 0, kHeaderSize - kUnalignedHeaderSize);

  // Copy serialized data.
  CopyBytes(data_ + kHeaderSize, payload->data(),
            static_cast<size_t>(payload->size()));
  uint32_t checksum =
      v8_flags.verify_snapshot_checksum ? Checksum(ChecksummedContent()) : 0;
  SetHeaderValue(kChecksumOffset, checksum);
}

So the layout is a fixed header plus a payload:

Offset Meaning
0 Magic number
+4 Version hash
+8 Source hash
+12 Flag hash
+16 Read-only snapshot checksum
+20 Payload length
+24 Code checksum
+28 ~ aligned Padding
+HeaderSize Payload begins

Brute-forcing the V8 version from VersionHash

If you have an unknown .jsc, the header’s VersionHash tells you which V8 produced it.

Version::Hash() hashes (major, minor, build, patch) into a single uint32_t:

// src/utils/hash.h
class V8_EXPORT Version {
 public:
  static uint32_t Hash() {
    return static_cast<uint32_t>(
        base::hash_combine(major_, minor_, build_, patch_));
  }
 private:
  static int major_;
  static int minor_;
  static int build_;
  static int patch_;
};

hash_combine lives in src/base/hashing.h:

// src/base/hashing.h
V8_INLINE size_t hash_combine(size_t seed, size_t hash) {
#if V8_HOST_ARCH_32_BIT
  const uint32_t c1 = 0xCC9E2D51;
  const uint32_t c2 = 0x1B873593;

  hash *= c1;
  hash = bits::RotateRight32(hash, 15);
  hash *= c2;

  seed ^= hash;
  seed = bits::RotateRight32(seed, 13);
  seed = seed * 5 + 0xE6546B64;
#else
  const uint64_t m = uint64_t{0xC6A4A7935BD1E995};
  const uint32_t r = 47;

  hash *= m;
  hash ^= hash >> r;
  hash *= m;

  seed ^= hash;
  seed *= m;
#endif  // V8_HOST_ARCH_32_BIT
  return seed;
}

template <typename T>
V8_INLINE size_t hash_value_unsigned_impl(T v) {
  switch (sizeof(T)) {
    case 4: {
      // "32 bit Mix Functions"
      v = ~v + (v << 15);  // v = (v << 15) - v - 1;
      v = v ^ (v >> 12);
      v = v + (v << 2);
      v = v ^ (v >> 4);
      v = v * 2057;  // v = (v + (v << 3)) + (v << 11);
      v = v ^ (v >> 16);
      return static_cast<size_t>(v);
    }
    case 8: {
      switch (sizeof(size_t)) {
        case 4: {
          v = ~v + (v << 18);
          v = v ^ (v >> 31);
          v = v * 21;
          v = v ^ (v >> 11);
          v = v + (v << 6);
          v = v ^ (v >> 22);
          return static_cast<size_t>(v);
        }
        case 8: {
          v = ~v + (v << 21);
          v = v ^ (v >> 24);
          v = (v + (v << 3)) + (v << 8);  // v * 265
          v = v ^ (v >> 14);
          v = (v + (v << 2)) + (v << 4);  // v * 21
          v = v ^ (v >> 28);
          v = v + (v << 31);
          return static_cast<size_t>(v);
        }
      }
    }
  }
  UNREACHABLE();
}

template <typename... Ts>
V8_INLINE size_t hash_combine(Ts const&... vs) {
  return Hasher{}.Combine(vs...);
}

Because the version ranges are small (major/minor ~0–20, build a few hundred, patch a few hundred), brute force is fast:

struct VersionTuple {
  int major;
  int minor;
  int build;
  int patch;
};

VersionTuple bruteforce_v8_version(uint32_t target_hash,
                                   int max_major = 20,
                                   int max_minor = 20,
                                   int max_build = 500,
                                   int max_patch = 200) {
  for (int major = 0; major < max_major; ++major) {
    for (int minor = 0; minor < max_minor; ++minor) {
      for (int build = 0; build < max_build; ++build) {
        for (int patch = 0; patch < max_patch; ++patch) {
          uint32_t h = static_cast<uint32_t>(v8::base::hash_combine(major, minor, build, patch));
          if (h == target_hash) {
            return VersionTuple{major, minor, build, patch};
          }
        }
      }
    }
  }
  return VersionTuple{-1, -1, -1, -1};
}

Or implement the hash by hand (standalone C):

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

// refer to v8/src/utils/version.h
//          v8/src/utils/version.cc

typedef struct {
    int major;
    int minor;
    int build;
    int patch;
} Version;

uint32_t hash_value_unsigned_32(uint32_t v) {
    v = ~v + (v << 15);
    v = v ^ (v >> 12);
    v = v + (v << 2);
    v = v ^ (v >> 4);
    v = v * 2057;
    v = v ^ (v >> 16);
    return v;
}

static size_t hash_combine(size_t seed, size_t hash) {
    const uint64_t m = 0xC6A4A7935BD1E995ULL;
    const uint32_t r = 47;

    hash *= m;
    hash ^= hash >> r;
    hash *= m;

    seed ^= hash;
    seed *= m;
    return seed;
}

uint32_t calculate_version_hash(int major, int minor, int build, int patch) {
    uint32_t seed = 0;
    seed = hash_combine(seed, hash_value_unsigned_32((uint32_t)major));
    seed = hash_combine(seed, hash_value_unsigned_32((uint32_t)minor));
    seed = hash_combine(seed, hash_value_unsigned_32((uint32_t)build));
    seed = hash_combine(seed, hash_value_unsigned_32((uint32_t)patch));
    return (uint32_t)seed;
}

Version bruteforce_v8_version(uint32_t hash) {
    for (int major = 0; major < 20; ++major) {
        for (int minor = 0; minor < 20; ++minor) {
            for (int build = 0; build < 500; ++build) {
                for (int patch = 0; patch < 200; ++patch) {
                    if (calculate_version_hash(major, minor, build, patch) == hash) {
                        Version found_version = {major, minor, build, patch};
                        return found_version;
                    }
                }
            }
        }
    }
    Version not_found_version = {-1, -1, -1, -1};
    return not_found_version;
}

For example, brute-forcing a sample hash yields 13.4.114.21 — so even a completely unknown .jsc can first be fingerprinted to a V8 version, then analyzed against the matching source tree.

Disassembly

V8 has a built-in disassembler. Running Node with --print-bytecode prints a huge disassembly dump before the script runs:

–print-bytecode output

So V8 already ships a complete bytecode disassembler — we just need to skip the source-compilation stage and feed it raw bytecode.

BytecodeArray::Disassemble → Object::Print

Tracing --print-bytecode leads to BytecodeArray::Disassemble, which calls Print from src/diagnostics/objects-printer.cc — the central dispatch for printing every V8 object. If you have any V8 Object, Print() dumps its bytecode, registers, and constant pool:

BytecodeArray::Disassemble → Object::Print

CodeSerializer::Deserialize

The question is how to turn raw bytecode back into an Object. Again CodeSerializer has the answer: CodeSerializer::Deserialize takes an AlignedCachedData and deserializes it into a SharedFunctionInfo — a real V8 object we can Print():

// 'src/snapshot/code-serializer.cc'
MaybeDirectHandle<SharedFunctionInfo> CodeSerializer::Deserialize(
    Isolate* isolate, AlignedCachedData* cached_data,
    DirectHandle<String> source, const ScriptDetails& script_details,
    MaybeDirectHandle<Script> maybe_cached_script) {
// ...
    const SerializedCodeData scd = SerializedCodeData::FromCachedData(
          isolate, cached_data,
          SerializedCodeData::SourceHash(source, wrapped_arguments,
                                         script_details.origin_options),
          &sanity_check_result);
      if (sanity_check_result != SerializedCodeSanityCheckResult::kSuccess) {
        if (v8_flags.profile_deserialization) {
          PrintF("[Cached code failed check: %s]\n", ToString(sanity_check_result));
        }
        DCHECK(cached_data->rejected());
        isolate->counters()->code_cache_reject_reason()->AddSample(
            static_cast<int>(sanity_check_result));
        return MaybeDirectHandle<SharedFunctionInfo>();
      }
// ...
}

Bypassing the SanityCheck

Deserialize runs SerializedCodeData::SanityCheck, SanityCheckJustSource, and SanityCheckWithoutSource against version, snapshot, and hash. Any failure rejects the cache. The brute-force way to disassemble: delete the checks.

The three SanityCheck functions

Hard-code all three to return kSuccess:

SerializedCodeSanityCheckResult SerializedCodeData::SanityCheck(
    uint32_t expected_ro_snapshot_checksum,
    uint32_t expected_source_hash) const {
  return SerializedCodeSanityCheckResult::kSuccess;
}

SerializedCodeSanityCheckResult SerializedCodeData::SanityCheckJustSource(
    uint32_t expected_source_hash) const {
  uint32_t source_hash = GetHeaderValue(kSourceHashOffset);
  return SerializedCodeSanityCheckResult::kSuccess;
}

SerializedCodeSanityCheckResult SerializedCodeData::SanityCheckWithoutSource(
    uint32_t expected_ro_snapshot_checksum) const {
  return SerializedCodeSanityCheckResult::kSuccess;
}

Once the bytecode is deserialized into a SharedFunctionInfo, walk the object graph and Print() every object in address order:

class V8ObjectExplorer {
public:
    explicit V8ObjectExplorer(v8::internal::Isolate* isolate) : isolate_(isolate) {}

    void Disassemble(v8::internal::Tagged<v8::internal::Object> start_obj) {
        DiscoverReachableObjects(start_obj);
        PrintDiscoveredObjects();
    }
private:
    void DiscoverReachableObjects(v8::internal::Tagged<v8::internal::Object> obj) {
        if (v8::internal::IsHeapObject(obj)) {
            Traverse(v8::internal::Cast<v8::internal::HeapObject>(obj));
        }
    }

    void Traverse(v8::internal::Tagged<v8::internal::HeapObject> obj) {
        // if compiled by node, sometimes the object will point to an address outside current bytecode scope,
        // which is normally located in snapshot_blob.bin (?). if this happens, we cannot read the object
        // data or type, so we must check readability and stop to avoid a crash.
        {
          {
            v8::internal::Tagged<v8::internal::Map> map_handle = obj->map();
            if (map_handle.ptr() == v8::internal::kNullAddress) {
              return;
            }
          }
          {
            // some object might have forwarding address.
            v8::internal::MapWord map_word = obj->map_word(v8::kRelaxedLoad);
            if (map_word.IsForwardingAddress()) {
                return;
            }
          }
        }

        if (!discovered_objects_.insert({obj.ptr(), obj}).second) {
            return;
        }

        if (v8::internal::IsBytecodeArray(obj)) {
            auto bytecode = v8::internal::Cast<v8::internal::BytecodeArray>(obj);
            auto consts = bytecode->constant_pool();
            for (int i = 0; i < consts->length(); i++) {
              DiscoverReachableObjects(consts->get(i));
            }
        } else if (v8::internal::IsSharedFunctionInfo(obj)) {
            auto sfi = v8::internal::Cast<v8::internal::SharedFunctionInfo>(obj);
            if (sfi->HasBytecodeArray()) {
                DiscoverReachableObjects(sfi->GetBytecodeArray(isolate_));
            }
        } else if (v8::internal::IsFixedArray(obj)) {
            auto fixed_array = v8::internal::Cast<v8::internal::FixedArray>(obj);
            for (int i = 0; i < fixed_array->length(); ++i) {
                DiscoverReachableObjects(fixed_array->get(i));
            }
        } else if (v8::internal::IsArrayBoilerplateDescription(obj)) {
            auto abd = v8::internal::Cast<v8::internal::ArrayBoilerplateDescription>(obj);
            DiscoverReachableObjects(abd->constant_elements());
        } else if (v8::internal::IsObjectBoilerplateDescription(obj)) {
            auto obd = v8::internal::Cast<v8::internal::ObjectBoilerplateDescription>(obj);
            for (int i = 0; i < obd->length(); i++) {
                DiscoverReachableObjects(obd->get(i));
            }
        }
    }

    static void segfault_jumper(int signal_number) {
        siglongjmp(V8ObjectExplorer::jump_buffer_, 1);
    }

    void PrintDiscoveredObjects() {
        v8::internal::OFStream os(stdout);

        void (*old_handler)(int);
        old_handler = signal(SIGSEGV, segfault_jumper);

        for (const auto& pair : discovered_objects_) {
            auto obj = pair.second;
            if (sigsetjmp(jump_buffer_, 1) == 0) {
              currently_printing_obj_addr_ = pair.first;
              v8::internal::Print(obj, os);
              currently_printing_obj_addr_ = 0;
            } else {
              fflush(stdout);
              os << std::endl << "!" << v8::internal::AsHex::Address(currently_printing_obj_addr_) << ": segmentfault, disassemble stop" << std::endl;
              currently_printing_obj_addr_ = 0;
            }
            fflush(stdout);
        }
        signal(SIGSEGV, old_handler);
        fflush(stdout);
    }
private:
    static sigjmp_buf jump_buffer_;
    static volatile v8::internal::Address currently_printing_obj_addr_;
    v8::internal::Isolate* isolate_;
    std::map<v8::internal::Address, v8::internal::Tagged<v8::internal::HeapObject>> discovered_objects_;
};
volatile v8::internal::Address V8ObjectExplorer::currently_printing_obj_addr_ = 0;
sigjmp_buf V8ObjectExplorer::jump_buffer_;

The object traversal has to be defensive: objects compiled by Node sometimes point outside the current bytecode scope (into snapshot_blob.bin), which isn’t readable — the checks against null maps and forwarding addresses keep the printer from crashing.

Decompilation (WIP)

The decompiler is being built against the View8 approach; it’s a work in progress, so this post stops at the disassembly layer. Full source is at aynakeya/v8asm.

One honest note: this whole thing started as an attempt to recover the bytecode-protected core of a specific Electron app, and the first attempt didn’t fully decompile — Electron’s patched V8 diverges enough from vanilla that a Node-targeted build isn’t a drop-in match. Matching the exact Electron patchset is the missing piece. The disassembly half is solid; the decompilation half is the next milestone.

Reference

Node build args used:

gn gen out/node.x64.release --args='is_debug=false v8_enable_disassembler=true v8_enable_object_print=true v8_enable_handle_zapping=false v8_enable_pointer_compression=false v8_enable_31bit_smis_on_64bit_arch=false v8_enable_hugepage=false v8_enable_fast_mksnapshot=false v8_win64_unwinding_info=true v8_enable_map_packing=false v8_enable_pointer_compression_shared_cage=false v8_enable_external_code_space=false v8_enable_sandbox=false v8_enable_v8_checks=false v8_enable_zone_compression=false v8_use_perfetto=false is_cfi=false'

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch