초보 웹 개발자가 알아야 할 기초적인 웹 해킹과 방어 ( SQL Injection 편 )

2021. 3. 24. 16:01개발노트/보안

 안녕하세요. Mysterico의 charles입니다. 이번엔 저번 xss에 이어 sql injection에 대해 알아보겠습니다.

 

들어가며

 이번 편은 일반적인 RDBMS(관계형 데이터베이스)를 사용했을때의 주의점에 대한 것입니다. 요즘은 query를 날리기 위한 라이브러리가 하도 잘 되어 있어서 그냥 기본적으로 막히게 되어 있습니다만 일부는 그런 방비가 되어 있지 않기도 하고, 그것들이 어떻게 동작하는지에 대한 것을 알아두는 것이 반드시 힘이 되어줄 것이기 때문에 적어보게되었습니다. 계속 강조하지만 보안 문제는 당연하다고 생각했던 것들, 사소하다고 생각했던 것들을 놓침으로서 발생합니다.

 이 내용 역시도 검색하면 넘쳐나게 나옵니다. 그리고 그나마 sql injection은 대부분 라이브러리 단에서 막히기 때문에 이런 것들이 문제가 되는 것은 많이 본 적이 없어서 좀은 다행이라고 보는 케이스입니다. 그리고 몇가지는 알아두시면 도움이 될 것들이 있을거라고 생각합니다.

 

SQL Injection 공격 ( Server 공격 )

 나대충 씨는 xss 공격을 방어하기 위한 내용을 적용하고서는 `흠 이젠 나의 철옹성을 뚫을 자는 없을 거야`라며 안심하고 있었습니다. 그리고 공개 채팅방에서 이 내용을 자랑했죠. 그리고 그것을 본 공격자씨는 `고작 그거 막았다고 히히덕대다니 용서할 수 없군. 쓴맛을 보여주마!`라며 다시 나대충씨의 서버를 향해 이것저것 만져대기 시작했습니다. 그리고 얼마 뒤 나대충씨는 게시판에 탈취당한 관리자 계정으로 도배된 것을 보게 되었습니다.

 SQL Injection은 서버를 대상으로 이루어지는 공격으로 SQL을 사용하는 일반적인 RDBMS를 대상으로 하는 공격입니다. SQL를 사용하므로 당연히 기본적인 SQL에 대한 지식을 필요로 합니다.

 

1. 실전 돌입

 여러가지로 시도해볼 방법이 있을 겁니다만 여기선 간단하게 로그인 쪽을 예시로 들어보겠습니다.

 

 일반적인 로그인 처리 과정은 간략하게 보자면 다음과 같을 것입니다.

 

  1. 클라이언트로 부터 로그인하고자하는 id와 비밀번호를 받는다.
  2. DB같은 데이터 저장고를 통해 로그인하고자하는 id와 비밀번호가 저장된 것과 일치하는지 확인한다.
  3. 일치한다면 세션이나 token 발급을 통해 로그인이 되었음을 증명하기 위한 방법을 알려준다.

 SQL Injection은 여기서 2번 과정을 향한 공격입니다. 다음의 코드를 보시죠.

class MemberService {
  public findMemberByNameAndPassword = async ({
    name,
    password,
  }: Member) =>
    openConnection(async (conn) => {
      try {
        const result = await conn.query(
          `SELECT * FROM member WHERE name = '${name}' AND password = '${password}'`
        );
        if (result.length < 1) {
          return;
        }
        return Member.create(result[0]);
      } catch (e) {
        console.log(e);
        throw e;
      }
    });
}

여기에서 밑의 부분이 바로 SQL입니다.

SELECT
  *
FROM
  member
WHERE
  name = '자바스크립트에 있는 name이라는 변수의 값' AND
  password = '자바스크립트에 있는 password라는 변수의 값'

위의 SQL을 풀이하자면 다음과 같습니다.

  • 다음의 조건을 반드시 만족해야한다.
    • name 컬럼의 값이 주어진 name이라는 이름의 변수의 값과 일치한다.
    • password 컬럼의 값이 주어진 password라는 이름의 변수의 값과 일치한다.
  • member 테이블에서 위의 조건에 만족하는 row의 모든 column의 값을 돌려달라.

 그럼 자바스크립트에서 name이라는 변수의 값이 user1 이고 password라는 변수의 값이 qwe123asd 라면 다음과 같은 SQL이 만들어져서 사용될 겁니다.

SELECT
  *
FROM
  member
WHERE
  name = 'user1' AND
  password = 'qwe123asd'

 여기까진 뭐 어렵지 않고 그렇구나 라고 할 수 있을 겁니다. 그러면 이러면 어떨까요?

 name이라는 변수에는 admin 이 들어가있고 password라는 변수에는 ' OR name = 'admin 이 들어가는겁니다. 그렇다면 다음과 같은 SQL이 만들어지겠죠.

SELECT
  *
FROM
  member
WHERE
  name = 'admin' AND
  password = '' OR name = 'admin'

 password = '' OR name = 'admin' 가 된겁니다.

 위의 SQL은 조건절이 AND와 OR의 우선순위에 의해 다음과 같이 바뀌게 됩니다.

  • name 이 admin 이고 password 가 비어 있는 문자열
  • 또는
  • name 이 admin

해서 나온 결과는 다음과 같이 보이는 군요

그럼 직접 한번 해보죠.

공격자씨는 이런 과정으로 SQL을 바꿔써서 나대충씨 게시판의 관리자 계정으로 로그인을 한 것입니다.

2. 어떻게 막아야합니까?

2-1. [핵심] 모든 서버는 production level에서는 절대로 발생한 에러(또는 예외)의 상세 메시지나 callstack을 응답에 포함해선 안된다.

 사실 이것은 SQL Injection 공격에 대한 방어 뿐이 아니고.. 다른 어떤 경우에서도 마찬가지입니다. 절대로 exception이나 error의 메시지나 호출 스택을 화면에 찍으면 안됩니다. 그건 오직 development level에서만 하도록 해야합니다. 공격자들은 에러 내용을 보고 역으로 어떻게 만들어져있다 라는 힌트를 얻게 됩니다. 프레임워크나 라이브러리의 버전이 노출될 경우 해당 프레임워크나 라이브러리의 취약점을 이용할수도 있고, 서버의 종류나 버전에 따라 취약점을 이용할 수도 있고, db의 이름이나 주소가 뭔지, 테이블이 어떻게 구성되어 있는지, 어떤 컬럼이 있는지 없는지 등등의 정보를 에러 내용을 통해 유추합니다. 그러니 최대한 정보를 주면 안됩니다. 에러나 예외가 발생할 시, 그걸 적당히 이쁘게 덮어서 그냥 문제가 발생했다 정도로만 표현해야합니다.

 

 물론 특정한, 또는 정확하게 handling 되고 있는 경우는 에러 코드를 정해서 클라인언트와 서버가 같이 쓰기도 합니다. 그런 케이스는 여기에 반드시 부합하지는 않습니다. 그런 케이스는 대부분 에러가 발생한 것을 서버가 알고 있고 그것을 클라이언트로 주기위해 매끈하게 다듬어서 주기 때문에 조금은 다릅니다. 하지만 일부의 경우 동작에 대한 힌트가 될 수 있기에 조심하긴 해야합니다.

 

 여러분이 보통 보면 어떤 웹서비스를 이용할때 비밀번호를 까먹는 경우가 종종 있었을 것입니다. 그리고 보는 에러 메시지는 대부분 `비밀번호가 틀렸다`가 아닌 `해당하는 id를 찾을 수 없다` 일 것입니다. 이것 역시의 지금 설명하고 있는 것과 같습니다. 실제로 아이디가 없어도 `해당하는 id를 찾을 수 없다`라고 알려주고, 비밀번호가 틀려도 `해당하는 id를 찾을 수 없다`라고 알려주는 것은 상세한 정보를 알려주지 않기 위해서입니다.

 

 또한 왠만한 곳은 다 그렇지만 http에서 server의 reponse header에 서버가 어떤 종류인지 알려주는 내용도 보통 지워버립니다. ( apache도 nginx도 기본적으로는 response header에 server 라는 키를 통해서 어떤 서버인지 적어놓습니다. 그리고 production에서는 그 내용을 지웁니다. ) 왜냐? 서버의 종류와 버전이 노출될 경우 해당 버전의 취약점이 존재할 시에는 그 취약점을 통해 공격받을 여지가 있기 때문입니다.

 

 지금의 예시에서 설명드리진 못했지만 나대충씨가 미처 막지못한 에러 덤프를 공격자씨는 계속해서 다르게 만들어내면서 어떻게 프로그램이 동작하는지, 어떤 db인지, db안의 table은 무엇이며 어떤 것들이 있는지 정보를 얻어냈을 것입니다. 절대로 공격자에게 그런 정보를 유추할 수 있게 하면 안됩니다.

 

 에러 내용을 표시하는 부분이 있다면 무조건 없애세요. 이것은 production level에서는 절대적으로 필수입니다.

2-2. [핵심] escape 처리

 결국 문제는 사용자의 입력을 준답시고 곧이 곧대로 사용한 것이 핵심적인 문제입니다. 그런 문제를 방지하기 위해 escape 처리가 제공됩니다. 위에서 나온 코드를 escape 처리를 하게 되면 다음과 같이 변경됩니다.

class MemberService {
  public findMemberByMatchedNameAndPassword = async ({
    name,
    password,
  }: Member) =>
    openConnection(async (conn) => {
      try {
        const result = await conn.query(`
SELECT
  *
FROM
  member
WHERE
  name = ${conn.escape(name)} AND
  password = ${conn.escape(password)}
        `);
        if (result.length < 1) {
          return;
        }
        return Member.create(result[0]);
      } catch (e) {
        console.log(e);
        throw e;
      }
    });
}

 지금 코드는 nodejs에서 mariadb를 사용할때 connection에서 제공되는 escape 함수를 적용한 것입니다. 다른 언어들에서도 이와 같이 escape 처리할 방법이 제공되고 있으니 확인하세요. 그리고 위의 escape 함수를 처리하고 나면 결국 다음과 같은 SQL이 됩니다.

SELECT
  *
FROM
  member
WHERE
  name = 'admin' AND
  password = '\' OR name = \'admin'

 절대로 특수문자를 통해서 장난질을 할 수가 없게 되죠.

 

2-2. [핵심] prepared statement 사용

 또는 제공이 된다면 prepared statement를 사용하는 것입니다. 위에 escape 적용한 것을 nodejs에서 mariadb 라이브러리가 제공하는 prepared statement 형식으로 바꾼다면 다음과 같이 됩니다. ( nodejs에서 mariadb 라이브러리가 제공하는 이것은 정확히 prapared statement가 아니라고 알고 있습니다. prepared statement에 대한 상세한 설명은 여기서 하지 않겠습니다. )

class MemberService {
  public findMemberByMatchedNameAndPassword = async ({
    name,
    password,
  }: Member) =>
    openConnection(async (conn) => {
      try {
        const result = await conn.query(
          `
SELECT
  *
FROM
  member
WHERE
  name = ? AND
  password = ?
        `,
          [name, password]
        );
        if (result.length < 1) {
          return;
        }
        return Member.create(result[0]);
      } catch (e) {
        console.log(e);
        throw e;
      }
    });
}

코드에 ? 가 들어갔네요. 그리고 순번에 맞게 2번째 인자로 array를 통해 값을 건네주고 있습니다. 이렇게 해서 실제로 생성된 SQL도 escape 한것과 동일합니다. 하지만 코드를 보건데 이 방법이 더 보기 좋네요.

2-2. [핵심] 라이브러리 사용 ( ORM도 좋습니다! )

 물론 그러함에도 불구하고 위에서 설명한 escape이나 prepared statement에 대한 것을 알고 계셔야합니다. 그리고 우린 일반적으로 이렇게 날쿼리를 그냥 쓰지 않죠. ORM만 따져도 Java에는 JPA ( hibernate가 정식으로 Java의 기능으로 들어온지 오래됬죠? )가 있고 typescript에도 TypeORM 이라는 것이 존재하고 php도 이것저것 많습니다.

 ORM을 안쓰신다구요? ruby의 active record나, Java의 Mybatis같이 쿼리를 날리기 위해 그것들은 관리하는 훌륭한 라이브러리들도 많습니다. 그리고 이미 이런 것을 쓰고 계실 거라고 생각합니다.

 만약 안쓰고 있다면 고려해보시길 추천드립니다. 이런 것들은 이미 sql injection 따위의 문제는 이미 해결된 상태로 기능이 제공됩니다. 복잡하게 고민할 필요 없이 사용하시면 편해집니다.

마치며

 이번 것도 생각보다 많은 지식이 없더라도 시도할 수 있는 간단한 공격입니다. 요즘은 뭐 옛날처럼 날쿼리 써대는 일이 별로 없어서 날이 갈수록 보기 힘든 케이스이긴 합니다만 그래도 알아두셔야합니다.

 저번의 xss도 그렇고 이번 것도 공격자가 적은 코드가 실행되는 것이 문제입니다. ( webshell 공격은 더 노골적으로 공격자의 코드를 실행하려듭니다. ) 반드시 유념하시길 바랍니다.

 

이전글 보기

2021.02.08 - [개발노트/보안] - 초보 웹 개발자가 알아야할 기초적인 웹 해킹과 방어 ( XSS 편 )