בפוסט הזה נדבר על מתקפת brute force. היא מתקפה שקל להבין אותה, קל לבצע אותה וקל מאוד להתגונן מפניה. נסביר מהי המתקפה ואיך אנחנו כמפתחים יכולים להתגונן מפניה.
מה היא מתקפת Brute Force?
מתקפת brute force היא מתקפה שבה פורץ מנסה לגלות את הסיסמה של משתמש במערכת שלנו באמצעות מעבר שיטתי על כל הסיסמאות האפשריות. הפרצה קיימת גם במנעולי מספרים פיזיים ולא רק בסיסמאות. בואו נחשוב על גנב שרוצה לפרוץ מנעול מספרים של אופניים בעל ארבע ספרות. אם הגנב לא יודע שום דבר על הצירוף שפותח את המנעול הוא יצטרך לנסות את כל הצירופים בין 0000 ל-9999, ובסך הכל 10,000 נסיונות במקרה הגרוע ביותר עבורו. חשוב לשים לב כי אם אנחנו, כבעליו החוקיים של האופניים, נחשוב כי מנעול בעל ארבע ספרות איננו בטוח מספיק בשבילנו ואנו רוצים במקומו מנעול בעל חמש ספרות, הגנב יצטרך כעת לנסות במקרה הגרוע 100,000 נסיונות. דהיינו, מאמץ קטן מצידנו גורם לגנב עבודה גדולה פי 10 – המאמץ של הפורץ לפרוץ את המערכת הוא אקספוננציאלי יחסית למאמץ שעושה המשתמש הלגיטימי כדי להגן עליה.

לכן כאשר הסיסמה ארוכה מספיק, פריצה באמצעות brute force איננה אפשרות מעשית. למשל, מספר האפשרויות עבור סיסמה בת 15 תווים, כאשר יש בערך 70 אופציות עבור כל תו (26 * 2 אותיות באנגלית, 10 ספרות ועוד כמה תווים מיוחדים) היא 7015. זה בערך 1027 אפשרויות. זה אומר שגם אם פורץ יצליח לנסות אלף ניסיונות בשניה הוא יצטרך 1017 שנים כדי לעבור על כל הסיסמאות האפשריות. זה 100 מיליון כפול מיליארד שנים.
הבעיה היא שהחסינות בפני התקפת brute force מניחה שהסיסמה אכן טובה: שהיא באורך המתאים ובעיקר שהיא לא מורכבת מצירוף שיהיה לנו נוח לזכירה – למשל צירוף של השם הפרטי ותאריך הלידה שלנו. במקרה שהפורץ מנחש שהסיסמה שלנו מורכבת מצירוף כזה הוא לא צריך אפילו לדעת איך קוראים לנו ומה תאריך הלידה שלנו – הוא פשוט יכול לנסות את הצירוף של כל השמות ותאריכי הלידה האפשריים, ולמרות שגם מספר הצירופים הללו נראה לנו גדול הרי שהוא קטן משמעותית ממספר האפשרויות עבור הסיסמה האידיאלית. נניח שיש אלף שמות פרטיים אפשריים ו-10,950 תאריכים אפשריים (מספר תאריכי הלידה האפשריים עבור משתמשים בין גיל 20 ל-50) ושיש 4 אפשרויות שונות לצירוף השם והתאריך (התאריך לפני השם או אחרי, השנה עם 2 ספרות או 4), הרי שפורץ שינסה רק עשר סיסמאות בשנייה יצטרך כחמישה ימים כדי לנסות את כל האפשרויות. אם הסיסמה היא רק תאריך הלידה והפורץ מריץ רק סיסמה בשניה, יספיקו לפורץ כ-30 שעות בשביל לגלות את הסיסמה של המשתמש. אם הפורץ יודע את הגיל המשוער של בעל החשבון בטווח של חמש שנים זמן הפריצה קטן עוד יותר.
סיסמאות פשוטות מדי
כל מי שנתקל בסיסמה קבוצתית שמשמשת כמה אנשים יודע שלרוב מדובר בסיסמאות שהתכונה העיקרית בבחירתן הייתה קלות הזכירה שלהן. יש מערכות בהן הסיסמה אמורה להיות מוכנסת כאשר אמורים לעשות פעולה דחופה, וגם במקרים כאלו יש נטיה לבחור סיסמאות שניתן להקליד בקלות. מפחיד לחשוב שבין 1962 ל-1977 הסיסמה לשיגור הטילים הגרעיניים של ארצות הברית הייתה 00000000 כדי לאפשר שיגור מהיר במקרה הצורך (מקור). בהנחה שיש סיסמאות רבות כאלו, אסטרטגיה נוספת שיכול הפורץ לאמץ היא להניח שהסיסמה שנבחרה היא סיסמה קלה, למשל 12345678 או qwertyuiop, ולנסות את הסיסמה מתוך רשימת הסיסמאות הנפוצות שניתן למצוא בקלות ברשת, למשל כאן. פורץ יכול לאסוף רשימת משתמשים גדולה ולנסות על כל אחד מהמשתמשים את כל אחת מאלף הסיסמאות הנפוצות, בתקווה שכמה מהמשתמשים אכן השתמשו באחת מהן. כאן יספיקו רק כמה עשרות אלפי ניסיונות כדי לפרוץ לחשבונות של אלפי משתמשים, משהו שיכול לקחת דקות ספורות בלבד
איך מריצים מתקפת Brute Force?
ההרצה של מתקפת brute force היא קלה למדי. מריצים קוד שמנסה להתחבר לאתר באמצעות כל הסיסמאות האפשריות. אם לא ידוע דבר על הסיסמה, מתחילים מהסיסמאות הקצרות וכאשר כל האפשרויות עבור סיסמה באורך מסוים מוצו עוברים לסיסמאות באורך גדול ב-1. אם ידוע משהו על הסיסמה, למשל שהיא תאריך כלשהו, אפשר להריץ את כל התאריכים האפשריים באופן סדרתי. את מספר הקריאות שאפשר להריץ במקביל אפשר לגלות בניסוי וטעיה שיבדוק את הקיבולת של השרת אליו אנחנו רוצים לפנות.
נראה דוגמה להרצת מתקפת brute force על שרת קטן שניצור. לשרת שלנו יש משתמש אחד ששם המשתמש שלו הוא 0501234567 והסיסמה שלו היא התאריך 01011980. אגב, נשים לב כי בשרת שלנו לא שמורה הסיסמה אלא ההאש שלה.
import hashlib | |
from bottle import run, post, request, HTTPResponse | |
def check_password(username, password): | |
hashed_password = hashlib.md5(password.encode('utf-8')).hexdigest() | |
return username == "0501234567" and hashed_password == '24fdf987d78b1f6d7c6008e7ecffeefb': # md5 of 01011980 | |
@post('/login') | |
def login(): | |
if check_password(request.json['username'], request.json['password']): | |
return HTTPResponse(status=200) | |
else: | |
return HTTPResponse(status=401) | |
run(host='localhost', port=8080) |
נוכל לנסות לפרוץ אל האתר בשתי דרכים. בדרך הראשונה אנו מנחשים שהסיסמה היא תאריך כלשהו בין 1970 ל-2000 ומנסים את כל התאריכים האפשריים. בדרך השנייה אנו מנחשים שהסיסמה היא אחת מאלף הסיסמאות הנפוצות ומנסים את כולן. בשתי הדרכים אנו מצליחים תוך זמן קצר להגיע אל הסיסמה הנכונה ולקבל מהשרת תגובה בקוד 200.
import itertools | |
import requests | |
def check_password(password): | |
data = {'username': '0501234567', 'password': password} | |
res = requests.post('http://localhost:8080/login', json=data) | |
if res.status_code == 200: | |
return True | |
return False | |
def common_passwords(): | |
res = requests.get('https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10-million-password-list-top-1000.txt') | |
passwrods = [s.strip() for s in res.content.splitlines()] | |
for password in passwords: | |
if check_password(password): | |
return password | |
return None | |
def birth_date_brute_force(): | |
for day, month, year in itertools.product(range(1, 32), range(1, 13), range(1970, 2000)): | |
password = f'{day}{month}{year}' | |
if check_password(password): | |
return password | |
return None | |
print(common_passwords()) | |
print(birth_date_brute_force()) |
לכן, אם אנחנו מנהלים מערכת של סיסמאות משתמשים, אסור לנו לפטור את עצמנו מהגנה מפני התקפת brute force בהנחה שההתקפה הזאת לא יעילה עבור סיסמה אידיאלית. המציאות היא שרבות מהסיסמאות אינן אידיאליות ואם לא נגן על המשתמשים שלנו מפני התקפת brute force הרי שאנו חושפים אותם לאפשרות שמישהו יצליח לנחש את הסיסמה של המשתמשים שלנו, ולו את המשתמשים הפחות מתוחכמים, ובלי הרבה מאמץ.
מה אנחנו, כמפתחים, צריכים לעשות?
עד כאן הסברנו מהי התקפת brute force ועד כמה קל להריץ אותה, עכשיו נראה מה הפתרון לבעיה, שלשמחתינו הוא פשוט מאוד. הפתרון הוא להגביל עבור כל משתמש את מספר קריאות ה-login שאנחנו מאפשרים בכל פרק זמן נתון. למשל, עד 10 ניסיונות התחברות ביום. ההגבלה הזו גורמת שלפורץ פוטנציאלי ייקחו 100 ימים לפרוץ סיסמה עם 4 מספרים. עבור סיסמה בעלת 6 תווים שכוללת אופציונלית גם תווים מספר אפשרויות הוא 706 וייקח לפורץ מעל 32 מליון שנה לעבור על כל האפשרויות. כפי שהזכרנו, פעמים רבות הפורצים מנסים ניחושים מושכלים ולא ממש את כל האפשרויות, אך עדיין כאשר פורץ יכול לנסות בכל יום 10 נסיונות, תהיה לו בשנה אפשרות לנסות עד 3650 נסיונות, וזה רחוק מכדי להספיק בשביל לפרוץ סיסמה סבירה.
איך נממש את זה בפועל? כמו תמיד, נרצה להימנע מלהמציא את הגלגל כאשר הבעיה שאנו פותרים היא בעיה נפוצה שכבר נפתרה על ידי רבים וטובים מאיתנו. לכן נשתמש בספרייה. בחיפוש קצר שעשינו מצאנו ספריה מתאימה עבור כל פריימוורק ווב שהעלנו בדעתנו. אנחנו נראה כאן דוגמאות עבור ספרייה קונקרטית בשם django-ratelimit ל-Django.
from ratelimit.decorators import ratelimit | |
@ratelimit(key='post:username', rate='10/d') # 1 | |
def login(request): | |
# ... yout logic here | |
pass | |
@ratelimit(key='ip', rate='1000/h') # 2 | |
def login(request): | |
# ... yout logic here | |
pass | |
rate = lambda request: None if request.user.is_authenticated() else '100/h' | |
key = lambda request: request.POST["search_type"] | |
@ratelimit(key=key, rate=rate) # 3 | |
def search(request): | |
# ... yout logic here | |
pass |
בפונקציה הראשונה אנחנו מגדירים מגבלה לפי הפרמטר username של קריאת ה-POST. זה המקרה הסטנדרטי שבו אנחנו רוצים למנוע נסיונות login רבים מדי עבור כל משתמש.
בפונקציה השנייה המגבלה היא לפי כתובת IP. זה מקשה על פורץ לנסות סיסמה נפוצה מאוד על חשבונות רבים.
בפונקציה השלישית יש אפשרות להגדיר מגבלה כרצוננו. הפונקציה מקבלת את אובייקט ה-request ומחזירה את המגבלה. זה מאפשר לנו חופש יותר גדול בבניית החוקים של ההגנה.
הספריה הזו משתמשת ב-caches של Django בשביל לאחסן נתונים. כברירת מחדל הקאש נשמר בזכרון כך שהוא מתאפס בכל ריסטרט ולא משותף בין מופעים שונים של אותו שרת. במקרים הפשוטים זה מספיק טוב. אם המערכת שלנו מורכבת יותר אפשר לקנפג שהמידע יישמר בדטהבייס (Redis או משהו דומה).
כמה הערות לסיום
פירצת brute force ממש קלה להרצה ומערכת שלנו שחשופה לפירצה כזו היא כמעט זהה למערכת המאפשרת גישה ללא סיסמה כלל. מצד שני, למזלנו, היא גם קלה מאוד לחסימה. בכל זאת עדיין מדהים לראות עד כמה הפירצה הזו עדיין נפוצה. באופן מפתיע למדי, באגים כאלו נמצאים באתרים הכי חשובים בעולם. בפוסט הזה מפברואר 2018 מתוארת פרצת brute force בפייסבוק. מפתח מצא דרך לאפס את הסיסמה של כל חשבון פייסבוק. הפוסט קל לקריאה ומדהים עד כמה המתקפה הזו פשוטה.
צריך לזכור שאנחנו צריכים לחסום לא רק את הקריאה ל-login אלא גם את הקריאה לאיפוס סיסמה וכדומה. כמו שראינו החולשה של פייסבוק הייתה במקרה כזה.
אפשר להגדיר חוקים מסובכים מאוד כמה סיסמאות אפשר לבדוק בכל זמן נתון. אבל גם כאן חל חוק ה-80/20. חוקים פשוטים מאוד יפתרו לנו 80 אחוז מהבעיה ואולי אפילו 99 אחוז. חוקים מדויקים יותר שלפיתוחם נקדיש זמן רב יגדילו את זמן הפריצה משנה לשנתיים או מ-200 אלף שנים ל-500 אלף והתועלת תהיה אפסית, בוודאי יחסית למאמץ שהשקענו עבור השיפור הנוסף.
גם אם חסמנו את המערכת שלנו אנחנו עדיין צריכים לחשוב על כמה שכבות של הגנה. למשל, נוסיף אימות דו שלבי באמצעות SMS או אימייל, נוסיף Captcha, נחסום גישה ל-IP לא רלוונטים במידה שהדבר אפשרי וכמובן שתמיד נשמור רק את ה-hash של הסיסמאות ולא את הסיסמאות עצמן.
פוסט מעניין במיוחד, קריא ומובן בהחלט.
הכתיבה קולחת, זורמת, מעוררת ענין וממוקדת מטרה. כמי עוסק במקצוע הכתיבה, מעניין אותי, מי הכותב?
תמשיכו עם זה
תודה
תודה!
תודה דוד! כתוב מעניין, מקצועי ועם זאת פשוט להבנה. אנא, המשך לתת בראש
תודה!
אתה יכול להירשם לקבל התראות במייל על פוסטים חדשים.
קראתי בעניין ובהנאה.
כתוב יפה ובהיר
תודה יורם.
כבוד הוא לנו לקבל ממך כזה פידבק.
בלוג מעולה. נרשמתי, מחכה כבר לפוסטים נוספים
כתיבה בהירה וברורה, עושה סדר בראש, מחכה לפוסטים נוספים.