-
Notifications
You must be signed in to change notification settings - Fork 1
Home
SSRF (Server Side Request Forgery) is a vulnerability used by attackers to do network internal requests when an URL is passed as an argument of your functions.
The impact of this vulnerability is completely related to what's accessible inside your internal network and what's the purpose of your function working on the URL attacker controlled argument.
Let's explain it with a Slite vulnerability discovered during a penetration testing.
Slite gives the ability to import content from various format into a note.
To lighten our media upload process, we directly upload files from browser into google cloud bucket.
Authenticated browser asks via GraphQL getFileUploadUrl
a signed unique upload URL.
Then it uses it to upload the file and then call importFilesFromUrlList
with the read URL as an argument.
The backend then download file from this URL and try to convert it to a note.
As you may guess, if you specify internal URL into importFilesFromUrlList
you'll exploit the SSRF and create a note from internal endpoint.
SSRF exploitation is not trivial as you should know internal mapping
In our case someone could call our internal JobManager and bypass Basic Authentication done by external reverse proxy.
In this specific case, the solution is quite simple as we know the URL should be from our own specific google bucket. We added an allowlist check on importFilesFromUrlList
to be sure it's our own bucket and even use google storage API instead of simple HTTP download.
OK, we understood what is an SSRF but our solution was very case specific. Most of the time you can't pass an allowlist of known external URL you will accept.
Blocklisting internal URL is fastidious and error prone. Moreover you can't imagine how many bypass techniques there are if you play with string comparison.
IPV6 ? IPV4 ? DNS ? Punycode ? decimal ipv4 ? Pick your poison...
As we could see, URL string to network request could be fooled by many techniques.
What's harder to fool then is network level communication itself.
If we could make all our dangerous requests from a single node and firewall it, it should fix the root problem.
A well known technique to centralize requests in one place is to use a proxy SOCKS.
We picked Dante proxy as it seems to be the most robust and efficient proxy SOCKS5 implementation.
Then we can:
Configure iptables
This is tedious and we all know someone who lock itself outside of the host...
Furthermore, it's really not compatible with kubernetes and micro services contenerized architecture sharing the same host...
Setup this proxy SOCKS on a different isolated network
A trade-off between practicality and security, why not ?
We will need to communicate with this proxy SOCKS using external connection so let's use SOCKS5 authentication.
Since the request carries the password in cleartext, this subnegotiation is not recommended for environments where "sniffing" is possible and practical.
Let's wrap it with TLS then !
Use Dante configuration
Dante provide a simple block and allow configuration which will operate at the network level after SOCKS5 negotiation.
This way we can integrate it in our private network, communicate with it but it will prevent further communication to go back into our private network.
At Slite we use NodeJS with Typescript and we use got to perform our HTTP requests.
We started to add a custom eslint
rule:
https://github.com/sliteteam/eslint-plugin-slite/blob/master/lib/rules/got-ssrf-protection.js
function lookForGotSSRFFunction(node) {
const [, options] = node.arguments;
return (
!!options &&
!!options.callee &&
["gotSSRFProtected", "gotSSRFUnprotected"].includes(options.callee.name)
);}
function set(cache, context, key, value) {
if (context.getFilename() !== cache.previousFilename) {
delete cache.save[cache.previousFilename];
cache.previousFilename = context.getFilename();
cache.save[context.getFilename()] = {};
}
cache.save[context.getFilename()][key] = value;}
function get(cache, context, key, fallback) {
if (cache.save[context.getFilename()]) {
return cache.save[context.getFilename()][key]
? cache.save[context.getFilename()][key]
: fallback;
}
return fallback;}
module.exports = {
meta: {
fixable: "code",
type: "problem",
},
create(context) {
const cache = {
previousFilename: undefined,
save: {},
};
return {
ImportDeclaration(node) {
if (
node.source.type === "Literal" &&
node.source.value === "got" &&
node.specifiers[0].type === "ImportDefaultSpecifier"
) {
set(cache, context, "importedGot", node.specifiers[0].local.name);
}
},
CallExpression(node) {
if (
node.callee.name === "require" &&
node.arguments[0].value === "got" &&
node.parent.id.type === "Identifier"
) {
return set(cache, context, "importedGot", node.parent.id.name);
}
const got = get(cache, context, "importedGot", "got");
if (
node.callee.name === got ||
(node.callee.object &&
node.callee.object.name === got &&
node.callee.type === "MemberExpression" &&
!node.callee.property.name.startsWith("mock"))
) {
const expliciteProtection = lookForGotSSRFFunction(node);
if (!expliciteProtection) {
context.report({
node: node.callee,
message:
`got calls should explicitly specify if they should be protected against SSRF` +
" with gotSSRFProtected or gotSSRFUnprotected function from helpers/ssrfProtection",
});
}
}
},
};
},};
Eslint rule to detect unprotected got calls
This rule looks for got
function calls second argument.
The second argument in got
is the HTTP request options.
We want our developers to explicitly wrap this options with gotSSRFProtected
or gotSSRFUnprotected
.
This way, everytime we use got
, eslint
will force us to think about the implication of this HTTP request.
Is it inter service communication ? → use gotSSRFUnprotected
then.
Is it user controlled URL ? → use gotSSRFProtected
!
What those two functions are doing ? You should ask yourself...
import { SocksProxyAgent } from 'socks-proxy-agent'import { Services } from '../services'
export function gotSSRFUnprotected<TOptions>(
services: Services,
options?: TOptions
) {
return options
}
export function gotSSRFProtected<TOptions>(
services: Services,
options?: TOptions
) {
if (services.config.ANTI_SSRF_PROXY_HOST !== '') {
const info = {
host: services.config.ANTI_SSRF_PROXY_HOST,
userId: 'proxy_username',
password: services.config.ANTI_SSRF_PROXY_PASSWORD,
}
return {
agent: {
http: new SocksProxyAgent(info),
https: new SocksProxyAgent(info),
},
...options,
} as TOptions
}
return options
}
Anti-SSRF got protection
The unprotected function does nothing and the _protected _function injects an HTTP agent to enforce usage or our SOCKS5 proxy.
That's it ! We could do the same with axios or any other HTTP client library.
Triple check the library you use doesn't support other schema than https?
as it will introduce nastier vulnerabilities (file://path/to/your/code...)!
Agents are only used for HTTP and HTTPS requests.
We created a docker image wrapping all together: https://github.com/sliteteam/anti-ssrf-proxy
You can double check the block list
If you want to protect Dante SOCKS5 authentication with TLS, you can mount TLS certificate and private key and add the environment variable TLS=true
.
It will bind Dante to localhost:1081
and use Stunnel4 to wrap it via 0.0.0.0:1080
.
And... as socks-proxy-agent
nodejs library doesn't support TLS wrapped SOCKS5, we've made a PR and a fork @slite/socks-proxy-agent
to introduce tls
option in the library.
We've made an internal route to help you identify SSRF:
import { Request, Response } from 'express'import got from 'got'import { v4 as uuid } from 'uuid'import { gotSSRFProtected } from '../../helpers/ssrfProtection'
export async function testSSRF(request: Request, response: Response) {
const { services } = response.locals
const proof = uuid()
services.sqreen.track('ssrf_triggered', { properties: { proof } })
try {
const pingURL = new URL(request.query.ping)
pingURL.searchParams.set('proof', proof)
await got(pingURL.toString(), gotSSRFProtected(services, { timeout: 3000 }))
// eslint-disable-next-line no-empty
} catch (e) {}
response.json({ ok: true })}
Bounty hunter SSRF helper
If one of our pod do a request on http://production-api-externals/test-ssrf?ping=http://attacker-controlled.host/
We will receive a Sqreen alert and the attacker will receive a proof token to send us with its exploitation report.