Denos Getting Digged Down 12

My first couple of articles all deal with low-level binary shenanigans. To this day, I think the allure of binary exploitation is very strong. Still, I’ve shifted more towards application and web security in the following years.

In this article, both things come together. Sort of. We’re looking at the JavaScript runtime Deno, specifically at its ability to produce stand-alone binaries.

The article acts as a companion piece to Deno Dig, a tool I’ve recently wrote which is able to extract application code and npm packages from said binaries. It does so on every platform (including a web version) and for every version of Deno.

But before we get ahead of ourselves…

What is Deno?

Deno is the open-source JavaScript runtime for the modern web.

Ryan Dahl, the creator of Node.js, announced Deno in 2018 after talking of 10 things he regrets about the former.

Deno strives to be secure by default, which is why sensible APIs like file access and networking are all opt-in. The dangers of running untrusted code should be as minimal as possible.

Other differentiators are built-in features like a code linter, a code formatter, a language server and an extensive standard library.

In essence, Deno provides a more curated, more secure and generally more cohesive package than Node's wild west ecosystem.

Oh, it also just released version 2.0.

Deno Compile

Deno applications can be compiled into stand-alone executables. This is useful for environments where the runtime is not available.

Up until recently, it worked by appending the application code to a binary and extracting it at runtime. Akin to what we did with our Linux.Doomsday virus.

A special executable called denort (“Deno runtime”) is used as the host for the application code. It is a stripped-down version of the normal Deno executable (no linter etc). Versions for a number of operating systems/architectures exist and are part of every new release (see assets).

Those different versions allow for cross compilation: Executables for foreign targets are simply fetched and the application code is added. No actual compilation step needed.

The feature was introduced in version 1.6.0 and has gone through a few iterations since then:

Node.js has a similar feature, but it’s experimental and you need a PhD in order to use it.

Handling Appended Data (< Deno 1.46)

Extracting an appended bundle is pretty simple. We’re going to look at an example for version 1.6.0, but the process is mostly the same for the other ones.

The last 16 bytes of a stand-alone binary have the following format:

 | Magic (8) | Bundle Offset  (8) |

The first eight magical bytes are always d3n0l4nd. The following eight bytes are the starting position of the bundled application data. Those bytes can be taken as is, meaning they are big-endian.

Let’s manually extract a bundle for clarification:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ file hello-v1.6.exe
hello-v1.6.exe: PE32+ executable (console) x86-64, for MS Windows

$ tail -c 16 hello-v1.6.exe | xxd
00000000: 6433 6e30 6c34 6e64 0000 0000 01f6 a600  d3n0l4nd........

$ dd if=hello-v1.6.exe of=bundle.js skip=0x1f6a600 bs=1
525+0 records in
525+0 records out
525 bytes transferred in 0.002867 secs (183118 bytes/sec)

$ cat bundle.js
function generateRandomString(length = 10) {
    const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let result = '';
    const charactersLength = characters.length;
    for(let i = 0; i < length; i++){
        const randomIndex = Math.floor(Math.random() * charactersLength);
        result += characters.charAt(randomIndex);
    }
    return result;
}
function greet(name) {
    return `Hello, ${name}!`;
}
const name = generateRandomString();
console.log(greet(name));
d3n0l4nd��%

We extract the magical delimiter and bundle offset pointer (0x1f6a600) in line four. Afterwards, we use dd to extract the bundle. Because we extract everything to the end of the file, delimiter and offset pointer are also present in line 28.

Handling Injected Data (>= Deno 1.46)

Simply appending the data has a couple of drawbacks, but the main one is missing support for code signing on Winows and macOS.

Because of this, the implementation nowadays doesn’t append the data but injects it into proper sections of the object file. A discussion can be found in the pull request. Under the hood it uses sui, an injection tool now part of Deno.

Different object file formats need different handling:

Thankfully, the object crate provides us with mostly straightforward means of extracting the data.

PE files, however, deserve some additional remarks: Resources are usually part of the .rsrc section. It took me a while to realize that in Deno's case, a custom .pedata section is used. Probably because of this dependency.

Let’s have a look at a Deno executable with PE-bear:

As we can see, .pedata contains the D3N0L4ND Resource Directory Entry. That entry contains a table which points to a Resource Data Description. It contains an offset (calculated from the start of the section) and a total size.

With that information, we can finally extract the blob we’re interested in.

Well, not quite yet! The offset is actually a relative virtual address (RVA), which means we have to translate it into a file offset.

Apart from the RVA and size of our blob, we also need two additional pieces of information:

  1. RVA of the containing section (.pedata)
  2. pointer to the raw section data

Afterwards we’re finally able to calculate the start of our blob (pinky swear!) by applying the following formular:

 (RVA of the resource) - (virtual address of .pedata) + (raw data pointer of .pedata)

But what exactly are we extracting anyway?

¿Qué es un eszip?

A compact file format to losslessly serialize an ECMAScript module graph into a single file

In other words: Eszip is the homebrew format used for storing our loot and a lot of metadata surrounding it.

Here’s the file format taken from their repository:

 | Magic (8) | Header size (4) | Header (n) | Header hash (32) | Sources size (4) | Sources (n) | SourceMaps size (4) | SourceMaps (n) |

 Header:
 ( | Specifier size (4) | Specifier (n) | Entry type (1) | Entry (n) | )*

 Entry (redirect):
 | Specifier size (4) | Specifier (n) |

 Entry (module):
 | Source offset (4) | Source size (4) | SourceMap offset (4) | SourceMap size (4) | Module type (1) |

 Sources:
 ( | Source (n) | Hash (32) | )*

 SourceMaps:
 ( | SourceMap (n) | Hash (32) | )*

Because the online eszip-viewer doesn’t appear to work at the time of writing, we need to use the CLI version.

Let’s create an eszip file first:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ cargo run --example eszip_builder https://deno.land/x/cowsay/cowsay.ts cow.eszip2

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.33s
     Running `target/debug/examples/eszip_builder 'https://deno.land/x/cowsay/cowsay.ts' cow.eszip2`
source: https://deno.land/x/cowsay@1.1/cowsay.ts
source: https://deno.land/x/cowsay@1.1/mod.ts
source: https://deno.land/x/cowsay@1.1/src/balloon.ts
source: https://deno.land/x/cowsay@1.1/src/cows.ts
source: https://deno.land/x/cowsay@1.1/src/replacer.ts
source: https://deno.land/x/cowsay@1.1/src/cows/cows.ts
source: https://deno.land/x/cowsay@1.1/src/faces.ts
source: https://deno.land/std@0.224.0/flags/mod.ts
source: https://deno.land/std@0.224.0/assert/assert_exists.ts
source: https://deno.land/std@0.224.0/assert/assertion_error.ts
source: https://deno.land/x/cowsay@1.1/src/models/IOptions.ts
source: https://deno.land/std/flags/mod.ts
source: https://deno.land/x/cowsay/cowsay.ts

$ du -sh cow.eszip2
 88K    cow.eszip2

Now we can look at the contents in a human friendly way:

 $ cargo run --example eszip_viewer cow.eszip2 > cow.txt

The output is quite long, so I’ll only show the section for the assertion_error.ts module (line 14 above).

1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
// cow.txt
============
Specifier: https://deno.land/std@0.224.0/assert/assertion_error.ts
Kind: JavaScript
---
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
/**
 * Error thrown when an assertion fails.
 *
 * @example
 * ```ts
 * import { AssertionError } from "https://deno.land/std@$STD_VERSION/assert/assertion_error.ts";
 *
 * throw new AssertionError("Assertion failed");
 * ```
 */ export class AssertionError extends Error {
  /** Constructs a new instance. */ constructor(message){
    super(message);
    this.name = "AssertionError";
  }
}

---
{"version":3,"sources":["https://deno.land/std@0.224.0/assert/assertion_error.ts"],"sourcesContent":["// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.\n// This module is browser compatible.\n\n/**\n * Error thrown when an assertion fails.\n *\n * @example\n * ```ts\n * import { AssertionError } from \"https://deno.land/std@$STD_VERSION/assert/assertion_error.ts\";\n *\n * throw new AssertionError(\"Assertion failed\");\n * ```\n */\nexport class AssertionError extends Error {\n  /** Constructs a new instance. */\n  constructor(message: string) {\n    super(message);\n    this.name = \"AssertionError\";\n  }\n}\n"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,qCAAqC;AAErC;;;;;;;;;CASC,GACD,OAAO,MAAM,uBAAuB;EAClC,+BAA+B,GAC/B,YAAY,OAAe,CAAE;IAC3B,KAAK,CAAC;IACN,IAAI,CAAC,IAAI,GAAG;EACd;AACF"}
============

Cool.

¿Qué son los metadatos?

Apart from the ezip, there’s also some structured metadata contained in the binary. It holds information about permissions, arguments and general configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
  "argv": [],
  "seed": null,
  "permissions": {
    "allow_all": true,
    "allow_env": [],
    "deny_env": null,
    "allow_hrtime": true,
    "deny_hrtime": false,
    "allow_ffi": [],
    "deny_ffi": null,
    "allow_net": [],
    "deny_net": null,
    "allow_read": [],
    "deny_read": null,
    "allow_run": [],
    "deny_run": null,
    "allow_sys": [],
    "deny_sys": null,
    "allow_write": [],
    "deny_write": null,
    "no_prompt": false
  },
  "location": null,
  "v8_flags": [],
  "log_level": null,
  "ca_stores": null,
  "ca_data": null,
  "unsafely_ignore_certificate_errors": null,
  "maybe_import_map": null,
  "entrypoint": "file:///home/runner/work/telecraft/telecraft/packages/cli/index.ts",
  "node_modules": {
    "Managed": {
      "node_modules_dir": false,
      "package_json_deps": null
    }
  },
  "disable_deprecated_api_warning": false,
  "unstable_config": {
    "legacy_flag_enabled": false,
    "bare_node_builtins": false,
    "byonm": false,
    "sloppy_imports": false,
    "features": [
      "kv"
    ]
  }
}

In this case, there’s not much to see. But we can spot the unstable kv feature from my guinea pig project’s README.

¿Qué es un sistema de archivos virtual?

Lastly, let’s have a quick look at how npm packages are serialized. A virtual file system (vfs) is used to represent the node_modules folder.

That vfs uses JSON and looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"name": "node_modules",
"entries": [{
    "Dir": {
        "name": "registry.npmjs.org",
        "entries": [{
            "Dir": {
            "name": "@discordjs",
            "entries": [{
                "Dir": {
                    "name": "builders",
                    "entries": [{
                        "Dir": {
                        "name": "1.8.2",
                        "entries": [{
                            "File": {
                                "name": "LICENSE",
                                "offset": 14050230,
                                "len": 10788}},
                            {
                            "File": {
                                "name": "README.md",
                                "offset": 14061018,
                                "len": 3529}},
                            {
                            "File": {
                                "name": "package.json",
                                "offset": 14064547,
                                "len": 2809
                            }}]}}]}}]}}]}}]}

The original file has 28690 lines after prettyfication!

As you’d expect, the vfs supports three different types of nodes, Directories, Files and Symlinks. Files have a name, a length and an offset.

¿Qué?

Enough theory! What problem does the Deno Dig tool solve? What’s the elevator pitch?

Here’s a demo video showing the command line version in action:

Pretty fast, right? That’s because it’s written in Rus… HOLD ON! There’s a reason.

Besides borrowing some structs from Deno itself, we can also make use of the eszip crate. This way, we don’t have to re-invent parsing and are future-proof should changes in their format arise.

If we already use eszip, why don’t we also use the sui crate? Great question!

Like always, PE files are to blame. Sui is designed to extract sections from the binary it’s part of. In case of PE files, it’s assumed that those run under Windows.

Fair enough.

But based on that assumption, they make use of platform specific APIs. That’s why we use the object crate instead: It allows for extraction on all platforms.

Deno Dig versions for many platforms exist, but why not simply check out the web version?

I hope people find a use for the tool. If something’s missing or not working, please let me know directly or via GitHub issues.

Resources and Acknowledgments