Co-authored with LoldemortXP
For this challenge, we are provided with the source code of a web application:
import { Application, Router, helpers } from 'https://deno.land/x/oak/mod.ts';
import { encode } from 'https://deno.land/std/encoding/hex.ts';
const HOST = '0.0.0.0';
const PORT = 8080;
const PROVIDER_TOKEN = Deno.env.get('PROVIDER_TOKEN');
const PROVIDER_HOST = Deno.env.get('PROVIDER_HOST');
const { FLAG } = await import(`http://${PROVIDER_HOST}/?token=${PROVIDER_TOKEN}`);
// no ssrf!
await Deno.permissions.revoke({ name: 'net', host: PROVIDER_HOST});
const router = new Router();
router
.get('/', async (ctx) => {
const encoded = new TextEncoder().encode(FLAG);
const hash_buff = await crypto.subtle.digest('sha-256', encoded);
const hash = new TextDecoder().decode(encode(new Uint8Array(hash_buff)));
const html = await Deno.readTextFile('./index.html');
ctx.response.body = html.replace('{HASH}', hash);
})
.get('/proxy', async (ctx) => {
const params = helpers.getQuery(ctx);
if (!params.url) {
ctx.response.body = 'missing url';
return;
}
const url = params.url;
const fetchResponse = await fetch(url);
ctx.response.body = fetchResponse.body;
})
;
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
app.addEventListener('listen', ({ hostname, port }) => {
console.log(`Listening on ${hostname}:${port}`);
});
await app.listen({ hostname: HOST, port: PORT });
As we can see, when navigating to the homepage, the SHA-256 of the flag is returned, while /proxy
just fetches another remote page and returns its body. However, the code is run as follows:
#!/bin/sh
deno run --no-prompt --allow-net="0.0.0.0:8080,$PROVIDER_HOST" --allow-read=. --allow-env ./main.js
The application uses Deno as its runtime, which gives the ability to provide fine-grained permissions to applications. Here, we can see that main.js
can read every file in its directory and can access environmental variables, but can only connect to itself and to the flag provider, which is a service that is exposed only in the internal network. Here’s its code:
import { Application, helpers } from 'https://deno.land/x/oak/mod.ts';
const HOST = '0.0.0.0';
const PORT = 8080;
let PROVIDER_TOKEN = Deno.env.get('PROVIDER_TOKEN');
const FLAG = await Deno.readTextFile('./flag.txt');
const app = new Application();
app.use((ctx) => {
const params = helpers.getQuery(ctx);
if (!params.token) return;
const token = params.token;
if (token === PROVIDER_TOKEN) {
ctx.response.body = `export const FLAG = '${FLAG}';`;
};
});
app.addEventListener('listen', ({ hostname, port }) => {
console.log(`Listening on: ${hostname}:${port}`);
});
await app.listen({ hostname: HOST, port: PORT });
The ability to connect to the flag provider gets revoked after the application retrieves the flag from it.
After experimenting for a while, we focused on the fetch()
function in the main app, which takes unsanitized input. Given the very restricted scope of reachable endpoints, we began looking for other URI schemes the Deno fetch implementation supports, and we stumbled upon this, which made us think about trying to perform an LFI attack. To test for it, we tried navigating to http://safe-proxy-web.chal.crewc.tf:8083/proxy?url=file:///home/app/main.js
. We got back the file main.js
, thus confirming the possibility of LFI.
To better understand the file organization inside the Deno /home/app
directory, we wrote a compose.yml
and we used it for deploying the two containers. With the containers up and running, we first tried to understand where Deno stores relevant files. This page seemed interesting; in particular, this section:
$DENO_DIR/deps is used to store files fetched through remote url import. It contains subfolders based on url scheme (currently only http and https), and store files to locations based on the URL path.
Since the application loads the flag by using import
, we thought about trying to use fetch
to include the file created in the deps
directory at import time.
This was the time to test locally to understand what was the right path of this file. We opened a shell in the container and searched in the .cache/deno
directory for the right file. We noticed that the deps
folder contains the imported modules in files whose names are hashes (presumably SHA-256 hashes), hence to import the right file we needed to know what’s the right hash for it. This was the part that took us the longest to complete, since we didn’t find a clear explanation of how these hashes are calculated. After a lot of time, since in the page linked above there’s written that the subfolders are based on url scheme, we decided to try and compute the hash of the path used for importing the flag, that is http://${PROVIDER_HOST}/?token=${PROVIDER_TOKEN}
. For doing this we needed to also find the token used when making the request. Luckily, in the .cache/deno
folder there’s a file called dep_analysis_cache_v1
that seems to be a database storing information about the imported modules. This file contains the full url used when making the request, thus leaking the token, that is 5a35327045b0ec9159cc188f643e347f
.
We replaced the redacted PROVIDER_TOKEN
in the Dockerfile with the real one, and we built and ran the containers again. Then we looked for the right file in the Deno cache, and after finding it we used it’s name (which we hoped would be the same also on the real instance of the challenge) to perform LFI. The full path is:
/home/app/.cache/deno/deps/http/safe-proxy-flag-provider_PORT8082/70ec621b0141f80c80d9e26b084da38df4bbf6b4b64d04c837f7b3cd5fe8482b
Passing this path in the url
parameter to /proxy
, we get the flag back: crew{file://_SSRF_in_modern_6f4544ec261423ce}