SQL Injection básico en JDBC

24 de mayo de 2016 • fjbalon

Buenos días. La siguiente entrada documenta cómo realizar una prueba de concepto que consiste en realizar una inyección SQL básico en un programa Java con JDBC (Java DataBase Connectivity). Además serán expuestas algunas posibles soluciones a esta vulnerabilidad. Para ello he creado una sencilla base de datos y he realizado un código de prueba en Java que consulta esta base de datos.

La base de datos simplemente se compondrá una tabla Usuarios que recoge cuatro atributos: (id), nombre, pass y secreto. En este último almacenaremos una información secreta de cada usuario, que sólo podrá verla este usuario una vez inicie sesión. Tanto la contraseña como el secreto se encuentran en texto plano, siendo lo ideal cifrarlos para darle seguridad. Pero al tratarse de una prueba básica lo dejaremos así para verlo con más facilidad.

Después, simplemente añadiremos varios usuarios con diferentes contraseñas. Podemos ver el script SQL:

CREATE DATABASE dbftwr CHARACTER SET utf8 COLLATE utf8_spanish2_ci;

CREATE TABLE Usuarios (
id INTEGER NOT NULL AUTO_INCREMENT UNIQUE,
nombre TEXT NOT NULL,
pass TEXT NOT NULL,
secreto TEXT NOT NULL,
PRIMARY KEY (id));

INSERT INTO Usuarios (nombre,pass,secreto) VALUES
('pedro','123456','Pedro realmente es Pablo'),
('pablo','987654321','Pablo es el padre de Juan'),
('miguel','miguel','Miguel no tiene amigos'); 

Para realizar las consultas a la base de datos e iniciar sesión, he utilizado un código que genere una ventana gráfica (Swing) con un pequeño formulario de inicio de sesión:

package es.fwhibbit.sqli;

import java.awt.event.ActionEvent;
import java.awt.event.WindowEvent;
import java.awt.event.WindowListener;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JTextField;
import javax.swing.SwingConstants;

public class SQLInjection extends JFrame implements WindowListener {
	private static final long serialVersionUID = 1L;
	
	JLabel 		lblUsuario 	= new JLabel("Usuario: ");
	JLabel 		lblPass		= new JLabel("Pass: ");
	JTextField 	txtUsuario 	= new JTextField("");
	JTextField 	txtPass		= new JTextField("");
	JLabel 		lblSecreto	= new JLabel("Secreto", SwingConstants.CENTER);
	JButton 	btnAceptar 	= new JButton("Aceptar");
	JButton 	btnLimpiar 	= new JButton("Limpiar");
	
	public SQLInjection() throws ClassNotFoundException, SQLException {
		
		setLayout(null);
		setSize(400,250);
		setTitle("SQL Injection Test");
		setLocationRelativeTo(null);
		setVisible(true);
		
		add(lblUsuario);	lblUsuario	.setBounds(1, 1, 100, 50);
		add(txtUsuario);	txtUsuario	.setBounds(100, 1, 300, 50);
		add(lblPass);		lblPass		.setBounds(1, 60, 100, 50);
		add(txtPass);		txtPass		.setBounds(100, 60, 300, 50);
		add(lblSecreto);	lblSecreto	.setBounds(0, 120, 400, 50);
		add(btnLimpiar);	btnLimpiar	.setBounds(95, 180, 100, 50);
		add(btnAceptar);	btnAceptar	.setBounds(205, 180, 100, 50);

		Class.forName("org.mariadb.jdbc.Driver"); 
		String dbpass = "";
		Connection connection = DriverManager.getConnection("jdbc:mysql://localhost/dbftwr", "root", dbpass); 
		Statement s = connection.createStatement();
		
		addWindowListener(this);
		// Botón cuya función es limpiar los campos de texto
		btnLimpiar.addActionListener((ActionEvent event) -> {
        	txtUsuario.setText("");
        	txtPass.setText("");
        });

        btnAceptar.addActionListener((ActionEvent event) -> {
			try {
				ResultSet rs = s.executeQuery("select secreto from Usuarios where (nombre = '" + txtUsuario.getText() + "' and pass = '" + txtPass.getText() + "');");
	        	rs.next();
				lblSecreto.setText(rs.getString(1));
			} 
			catch (SQLException e) {e.printStackTrace();}
        });
		
	}
	public static void main(String[] args) throws NumberFormatException, IOException, ClassNotFoundException, SQLException {new SQLInjection();}

	public void actionPerformed(ActionEvent ae) {} 
	public void windowActivated(WindowEvent we) {}
	public void windowClosed(WindowEvent we) {}
	public void windowClosing(WindowEvent we) {this.setVisible(false);}
	public void windowDeactivated(WindowEvent we) {}
	public void windowDeiconified(WindowEvent we) {}
	public void windowIconified(WindowEvent we) {}
	public void windowOpened(WindowEvent we) {}
}

Ejecutando el programa, podemos observar el siguiente resultado. Somos Pedro y conocemos su contraseña: 123456. Por lo que será nuestro conejillo de indias, de tal forma que tras iniciar sesión conoceremos el secreto de Pedro:

Los dos bloques de código que nos ocupan son los siguientes: el bloque de conexión a la base de datos y el contenido del botón de aceptar, que almacena la consulta.

Class.forName("org.mariadb.jdbc.Driver"); 
String dbpass = "";
Connection connection = DriverManager.getConnection("jdbc:mysql://localhost/dbftwr", "root", dbpass); 
Statement s = connection.createStatement();

btnAceptar.addActionListener((ActionEvent event) -> {
	try {
		ResultSet rs = s.executeQuery("select secreto from Usuarios where (nombre = '" + txtUsuario.getText() + "' and pass = '" + txtPass.getText() + "');");
    	        rs.next();
		lblSecreto.setText(rs.getString(1));
	} 
	catch (SQLException e) {e.printStackTrace();}
});

Como vemos la consulta select secreto from Usuarios where (nombre = '" + txtUsuario.getText() + "' and pass = '" + txtPass.getText() + "'); es vulnerable a un SQL Injetion, ya que al almacenar las variables directamente en la sentencia SQL, enviará la cadena de texto tal y como lo recoja de los campos de texto. Por lo que podemos jugar. Una consulta típica, siguiendo el ejemplo de pedro, sería:

select secreto from Usuarios where (nombre = ' pedro ' and pass = ' 123456 ');

Esta sentencia mostraría el secreto de la tabla Usuarios, donde el nombre sea pedro y su contraseña 123456. Con esto comprobamos que la contraseña corresponda al usuario. Al ser nombre igual a pedro devolverá un booleano true (verdadero); al ser la contraseña 123456 también devolverá un booleano true. True AND True = True: Sesión iniciada correctamente. Pero ¿y si insertamos código SQL, para engañar al servidor y que nos muestre el secreto de quien queramos, sin necesidad de conocer su contraseña?. Esto lo podemos hacer aprovechando esta vulnerabilidad.

select secreto from Usuarios where (nombre = ' pedro' or '1'='1 ' and pass = '');

De esta forma hemos injectado código: asignamos el nombre, es pedro (es true). O bien (1=1 -sentencia obviamente cierta, verdadera- y pass='' (cadena en blanco, obviamente falsa). True OR (True AND False) = True OR False = True. Obtenemos finalmente un booleano verdadero, sin conocer la contraseña, lo que nos devolverá el secreto de este usuario. Podemos realizar una prueba con el usuario Miguel, el cual no conocemos su contraseña.

¿Cómo podemos solucionarlo? Con el fin de evitar este tipo de ataques sobre nuestro programa deberemos utilizar el método seguro: sustituir el Statement por el PreparedStatement. Vemos el bloque de código que hemos de editar: en primer lugar comentamos nuestro Statement, ya que no le daremos uso. Lo sustituimos por el PreparedStatement, donde le indicamos directamente la sentencia SQL; sustituyendo los elementos a insertar por signos de cerrar interrogación (?) (omitir comillas). Con la órden ps(PreparedStatement).setString y el número de posición del elemento que hemos colocado en la sentencia, indicaremos el valor a dar, ya sea una variable o un getText() como en mi caso.

Class.forName("org.mariadb.jdbc.Driver"); 
String dbpass = "";
Connection connection = DriverManager.getConnection("jdbc:mysql://localhost/dbftwr", "root", dbpass); 
//Statement s = connection.createStatement();

btnAceptar.addActionListener((ActionEvent event) -> {
	try {
		PreparedStatement ps = connection.prepareStatement("select secreto from Usuarios where (nombre=? and pass=?)");
		ps.setString(1, txtUsuario.getText());
		ps.setString(2, txtPass.getText());
		ResultSet rs = ps.executeQuery();
		rs.next();
		
		lblSecreto.setText(rs.getString(1));
	} 
	catch (SQLException e) {e.printStackTrace();}
});

Otra alternativa, también válida, para solventar esta vulnerabilidad, es hacer una única sentencia sin posibilidad de edición. Y a su vez jugar con las respuestas. El código será similar, de forma que:

Class.forName("org.mariadb.jdbc.Driver"); 
String dbpass = "";
Connection connection = DriverManager.getConnection("jdbc:mysql://localhost/dbftwr", "root", dbpass); 
Statement s = connection.createStatement();

btnAceptar.addActionListener((ActionEvent event) -> {
	try {
		ResultSet rs = s.executeQuery("select * from Usuarios;");
		while (rs.next()) {
    		if (rs.getString(2).equals(txtUsuario.getText()) && rs.getString(3).equals(txtPass.getText())) {
    			lblSecreto.setText(rs.getString(4));
    			break;
    		}
    		else {lblSecreto.setText("ERROR");}
		}
	} 
	catch (SQLException e) {e.printStackTrace();}
});

Como podemos ver, el bloque de conexión se mantiene intacto a la primera opción. El interior del botón simplemente tendrá la sentencia select * from Usuarios. De los resultados que obtenga, mientras haya irá recorriendo de uno en uno, comprobando que el contenido del campo de texto de usuario sea el mismo que el nombre de usuario de la tabla, Y el contenido del campo de texto de la contraseña sea igual a la contraseña del usuario de la tabla. Si estas condiciones se cumplen, se mostrará el secreto, sino, mostrará un error.

De esta forma, al no modificar la sentencia directamente, sino jugar con sus resultados evitaremos esta vulnerabilidad en concreto. Así, hemos explotado y solucionado el riesgo que supone la vulnerabilidad de SQL Injection en nuestro software, evitando así posibles ataques a nuestra base de datos.