وقتی شاخص Postgres با Bcrypt ملاقات می کند

سلام آنجا! در پست قبلی “آنچه که حادثه Okta Bcrypt می تواند در مورد طراحی API های بهتر به ما آموزش دهد” ، ما در مورد حد 72-chars از ارزش ورودی الگوریتم هشویی Bcrypt که باعث ایجاد یک حادثه امنیتی بسیار بزرگ در صنعت شد ، بحث کردیم. این به من در مورد نمونه دیگری از سوء استفاده از bcrypt یادآوری کرد که من ، شخصاً ، چند سال پیش در حالی که در مورد یک مسئله عملکرد کاملاً نامطبوع با یکی از خدمات تحقیق کردم ، روبرو شدم. بیایید درست به آن پرش کنیم!
مدیر محصول یکی از تیم های همسایه به من نزدیک شد و از من پرسید که آیا می توانم به آنها در تخریب عملکردی که اخیراً با جدیدترین ویژگی خود تجربه شده بود ، کمک کنم ، وقتی از کاربران خواسته شد تا وارد SSN (شماره تأمین اجتماعی) خود شوند ، و آنها ” D با داده های شخصی در مورد آنها داشبورد دریافت کنید. معماری ساده مانند این به نظر می رسید:
همانطور که مشاهده می کنید ، جریان شامل 3 مرحله است:
- کاربر وارد SSN خود در UI می شود و UI آن را به سرویس داخلی می فرستد.
- سرویس داخلی بررسی می کند که آیا داده های کاربر با چنین SSN در حال حاضر در DB (Postgres) موجود است: اگر بله ، این داده ها به UI بازگردانده می شوند
- در غیر این صورت ، درخواست به API شخص ثالث برای واکشی داده های مورد نظر انجام می شود ، سپس در حال پردازش ، ذخیره در DB و بازگشت به کاربر است
ماهیت داده های کاربر نیازی به به روزرسانی در زمان واقعی نداشت ، بنابراین همان داده ها برای ماه ذخیره و استفاده شد ، پس از آن در نظر گرفته شد که منقضی شده و توسط Cronjob حذف شده است. یک سیستم بسیار ساده
مسئله (تیم تجربه می کرد) این واقعیت بود که 10+ ثانیه طول کشید تا جریان 3 مرحله ای را که در بالا بحث کردیم ، دنبال کرد و همین امر منجر به UX وحشتناک و نرخ گزاف گویی شد. این حس خوبی ایجاد کرد ، زیرا میانگین توجه کاربران امروزه کمتر از 10 ثانیه است. اما چرا اینقدر کند بود؟
مسئله اشکال در تنظیم تولید قابل تکرار بود ، سیاهههای مربوط/معیارها با سرنخ های علت آن چندان مفید نبودند. بنابراین ، من کد پروژه را به لپ تاپ خود کلون کردم و یک نمونه Postgres را از طریق Docker Compose راه اندازی کردم. علاوه بر این ، من MITMProxy را شروع کردم تا بتوانم درخواست های HTTP را در دستگاه خود رهگیری و بازرسی کنم و الگویی از درخواست را به API خدمات داخلی با SSN خودم در Postman ایجاد کردم. تنظیم اشکال زدایی من آماده بود ، بنابراین زمان اجرای برنامه و تست برنامه بود.
اجرای آن به صورت محلی
در کمال تعجب من ، پس از ارسال درخواست از Postman ، در مورد ده ها میلی ثانیه پاسخی دریافت کردم. میتپروکسی نشان داد که در واقع درخواست API شخص ثالث وجود دارد ، اما حداقل تأخیر داشت.
هوم … جالب من سعی کردم دوباره همان درخواست را تکرار کنم ، و دوباره یک جواب سریع دریافت کردم ، اما این بار بدون هیچ گونه تماس با API شخص ثالث. این یک حس عالی بود ، زیرا اطلاعات کاربر من در Postgres وجود داشت. من سعی کردم چند بار دیگر همان درخواست ها را ارسال کنم ، اما با تولید مثل مسئله شانس نداشتم.
با وجود شکست ، این یک سرنخ خوب به من داد: به نظر می رسید که می توانم API شخص ثالث را از لیست مظنونان یا حداقل در حال حاضر خارج کنم. البته ذکر این نکته حائز اهمیت است که تنظیمات محلی با تولید متفاوت است ، بنابراین همیشه این احتمال وجود دارد که ممکن است چیزی در حال کاهش سرعت ترافیک Egress باشد. با این حال ، برای سادگی در تحقیقات ، من تصمیم گرفتم که قبل از بررسی عوامل خارجی احتمالی ، مانند پروکسی های ابر ، فیلترهای امنیتی و غیره ، هیچ مشکلی در برنامه ایجاد نکرده باشد.
فهمیدم که وقت آن است که نگاهی به کد بیندازیم. اگر می خواهید به دنبال آن باشید ، در اینجا نمونه های کد که تنظیماتی را که ما در مورد آن بحث می کنیم تولید می کنند ، بررسی کنید README.md
پرونده برای نحوه اجرای دستورالعمل ها.
BTW ، اگر وبلاگ من را دوست دارید و نمی خواهید پست های جدید را از دست ندهید ، مشترک شدن در خبرنامه من را در اینجا در نظر بگیرید. پس از انتشار یک پست جدید ، یک ایمیل دریافت خواهید کرد.
مرور کد
ممکن است بدانید که یک جمله “همیشه DNS” وجود دارد ، که به دلیل عدم وجود برخی از خدمات به دلیل عدم وجود برخی از خدمات ، اعمال می شود. از تجربه من ، هنگامی که یک مشکل عملکرد با یک برنامه نسبتاً ساده وجود دارد ، “این اغلب بانک اطلاعاتی است”. بنابراین ، حتی اگر ، من شروع به پیمایش کد منبع از بالا به پایین (API -> DB) کردم ، انتظار نداشتم چیزی مشکوک در لایه های بالاتر از آن که نمایش داده های DB را اجرا می کند ، ببینم. و ، در واقع ، بیت های جالب کد 2 کارکرد از Repo
ساختار مسئول درج کاربر جدید در DB و انتخاب کاربر خاص از DB توسط SSN. در اینجا چگونه به نظر می رسید:
func (r *Repo) SelectUserBySsn(ssn string) (*common.User, error) {
now := time.Now()
var user common.User
err := r.dbPool.QueryRow(
context.Background(),
"SELECT id, user_info FROM users WHERE ssn_hash = crypt($1, ssn_hash)",
ssn,
).Scan(&user.Id, &user.UserInfo)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
fmt.Println(err)
return nil, err
}
fmt.Println("Time taken to get user:", time.Since(now))
return &user, nil
}
func (r *Repo) InsertUser(user common.NewUserEntity) error {
_, err := r.dbPool.Exec(
context.Background(),
"INSERT INTO users (id, ssn_hash, user_info) VALUES ($1, crypt($2, gen_salt('bf')), $3)",
user.Id, user.Ssn, user.UserInfo,
)
if err != nil {
fmt.Println(err)
return err
}
return nil
}
در crypt($1, ssn_hash)
وت crypt($2, gen_salt('bf')
قطعات بلافاصله چشمانم را به خود جلب کردند: “آها ، بنابراین ما SSN های ساده را در DB ذخیره نمی کنیم ، بلکه نسخه های هش دار”. کشف جالب! این هیچ نکاتی در مورد الگوریتم دقیق که برای هشویی استفاده شده است ، به من نمی دهد ، اما یک جستجوی سریع Google توسط gen_salt('bf')
بخشی به من کمک کرد تا کشف کنم که این یک bcrypt است. و در واقع ، بعد از اینکه من این کار را کردم SELECT * FROM users
در مجموعه محلی من متوجه شدم که ssn_hash
ستون این مقدار را داشت $2a$06$r4UXr0F80tz57DGzCQBf6uIpQealvM3Rb6E/TvsyFkJJaiLXt9J8W
، و $2a$
پیشوند در واقع یک bcrypt بود. خوب ، ما به چیزی هستیم! این احتمال وجود دارد که برخی از شما که متخصص در رمزنگاری و/یا بانک اطلاعاتی هستند (یا حداقل ، پس از آن) ، می توانید دلیل عملکرد ضعیف برنامه را ببینید (Kudos برای شما!) ، اما این نبود مورد برای من ، بنابراین مجبور شدم حفر کنم.
فهمیدم که DB در حال حاضر بهترین رهبری من است ، بنابراین من از کد منبع به کنسول Postgres در ایده Intellij خود پریدم و این پرس و جو را اجرا کردم: SELECT * FROM users WHERE ssn_hash = crypt('0852285111', ssn_hash)
(0852285111
SSN جعلی من در این مثال است). من نتیجه را در 9 میلی ثانیه بدست آوردم ، که بسیار سریع بود و هیچ مشکلی در مورد عملکرد نداشتم.
اما پس از آن من به DB تولید وصل شدم و همان پرس و جو را اجرا کردم. برای دریافت پاسخ مشابه 15 ثانیه طول کشید. وای جالب تر این واقعیت بود که واکشی همه کاربران موجود از طریق SELECT * FROM users
فقط 6 میلی ثانیه گرفت. گیج کننده ، اینطور نیست؟
علیرغم اینکه کمی گیج شده ام ، قطعاً پیشرفت خوبی احساس می کردم ، زیرا من یک سرنخ قوی داشتم که DB در اینجا تنگنا بود. و مشخص شد که شکست های من در تولید مثل محلی به این دلیل است که جدول محلی من فقط 1 ردیف دارد ، در حالی که تولیدی از آن لحظه 5000 کاربر داشت و ساعت به ساعت رشد می کرد. من می توانستم اشکال زدایی مسئله را در نمونه تولید پس از تولید ، همانطور که در آنجا قابل تکرار بود ، اشکال زد داده ها در آنجا بنابراین ، در ابتدا ، من می خواستم عکس فوری از تولید DB بگیرم و از آن به صورت محلی استفاده کنم ، اما فهمیدم که این ایده خیلی بهتری نیست: گرفتن عکس فوری ممکن است یک عملیات پرهزینه برای Postgres باشد. به علاوه ، پیامدهای حریم خصوصی من در مورد کپی کردن داده های حساس مشتریان به دستگاه محلی من وجود دارد. به همین دلیل من با گزینه سوم وارد کردن داده های جعلی زیادی در DB محلی خود رفتم.
آن را جعلی کنید تا زمانی که آن را درست کنید
این واقعیت که جدول ما بسیار ساده بود و فقط از 3 ستون تشکیل شده بود ، کار را بسیار ساده کرد. بنابراین ، من به نوشتن این کد تولید داده جعلی پایان دادم:
func GenRandomUsers(num int) []common.NewUserEntity {
users := make([]common.NewUserEntity, num)
for i := 0; i < num; i++ {
users[i] = GenRandomUser()
}
return users
}
func GenRandomUser() common.NewUserEntity {
ssn := genRandomSsn()
fmt.Println("Generated SSN:", ssn)
return common.NewUserEntity{
Id: uuid.New().String(),
Ssn: ssn,
UserInfo: GenRandomUserInfo(),
}
}
func GenRandomUserInfo() string {
return fmt.Sprintf("Some info %s %s", strconv.FormatInt(time.Now().UnixMilli(), 10), uuid.New().String())
}
func genRandomSsn() string {
ssn := ""
for i := 0; i < 10; i++ {
ssn += strconv.Itoa(rand.IntN(10))
}
return ssn
}
و از آن در داخل استفاده کرد fillDb
عملکردی که من در برنامه راه اندازی برنامه ایجاد کردم:
func fillDb(repo *db.Repo) {
users, err := repo.SelectUsers()
if err != nil {
panic(err)
}
if len(users) > 0 {
fmt.Println("users already exist, skipping db fill")
return
}
fmt.Println("generating random users...")
newUsers := utils.GenRandomUsers(5000)
fmt.Println("inserting random users...")
err = repo.InsertUsers(newUsers)
if err != nil {
panic(err)
}
fmt.Println("inserted random users")
}
هنگامی که من برنامه را اجرا کردم ، بیش از 15 ثانیه طول کشید تا وارد کردن کاربران ، که من عجیب و غریب پیدا کردم ، به عنوان InsertUsers
کد فقط 1 تماس با DB ساخته شده است:
func (r *Repo) InsertUsers(users []common.NewUserEntity) error {
now := time.Now()
query := "INSERT INTO users (id, ssn_hash, user_info) VALUES "
var args []interface{}
for i, user := range users {
query += fmt.Sprintf("($%d, crypt($%d, gen_salt('bf')), $%d),", i*3+1, i*3+2, i*3+3)
args = append(args, user.Id, user.Ssn, user.UserInfo)
}
query = query[:len(query)-1] // remove the trailing comma
_, err := r.dbPool.Exec(
context.Background(),
query,
args...,
)
if err != nil {
fmt.Println(err)
return err
}
fmt.Println("Time taken to insert users:", time.Since(now))
return nil
}
برای من واضح تر و واضح تر شد crypt($%d, gen_salt('bf'))
بخشی از آن مقصر بود ، اما من نفهمیدم که چرا هنوز چنین بود. به هر حال ، هدف میانی به دست آمد ، زیرا در آن زمان از زمان ، می توانم مسئله عملکرد را در تنظیم محلی خود نیز تولید کنم ، SELECT * FROM users WHERE ssn_hash = crypt('0852285111', ssn_hash)
پرس و جو به همان اندازه در تولید DB کند شد ، حتی اگر SSN من جزو سوابق موجود DB نبود.
من تصمیم گرفتم برای لحظه ای مکث کنم و واقعیت ها را بررسی کنم:
- دلایل عملکرد آهسته نمایش داده شدگان DB بود
- عملکرد با رشد تعداد سوابق موجود در جدول DB تخریب شد
- چیزی با ماهی ماهی است
crypt()
عملکرد برای هر دو لیست درج و انتخاب کنید
واقعیت دوم یک نکته خوب بود ، بنابراین من تصمیم گرفتم بررسی کنم که برنامه ریز پرس و جو Postgres می تواند در مورد برنامه اجرای پرس و جو به من بگوید:
EXPLAIN
SELECT *
FROM users
WHERE ssn_hash = crypt('0852285111', ssn_hash);
کلمه کلیدی EXPLAIN
در این حالت باعث می شود که Postgres برای ساختن برنامه برای پرس و جو ایجاد شود ، اما آن را اجرا نکنید. نتایج مانند این بود:
Seq Scan on users (cost=0.00..182.00 rows=25 width=138)
" Filter: (ssn_hash = crypt('0852285111'::text, ssn_hash))"
آه ، Seq Scan on users
به ما این نکته را می دهد که Postgres در حال انجام یک اسکن پی در پی است ، به این معنی که برای یافتن تطبیق هر ردیف جدول را طی می کند – O(n)
اگر می خواهیم از پیچیدگی الگوریتمی در اینجا استفاده کنیم. این می تواند ارتباط بین افزایش تعداد ردیف ها و تخریب عملکرد را توضیح دهد.
بیایید اضافه کنیم ANALYSE
کلمه کلیدی برای اجرای پرس و جو و دریافت معیارهای بیشتر:
EXPLAIN ANALYSE
SELECT *
FROM users
WHERE ssn_hash = crypt('0852285111', ssn_hash);
خروجی:
Seq Scan on users (cost=0.00..182.00 rows=25 width=138) (actual time=15037.772..15037.774 rows=1 loops=1)
" Filter: (ssn_hash = crypt('0852285111'::text, ssn_hash))"
Rows Removed by Filter: 5000
Planning Time: 0.048 ms
Execution Time: 15037.788 ms
که آنچه را که ما در بالا دیده بودیم اثبات می کند.
بنابراین ، من فکر کردم که معما را حل کردم: ssh_hash
ستون فاقد شاخص بود. من تقریباً به خودم افتخار می کردم ، اما این یک لحظه بسیار کوتاه بود ، همانطور که بعد از مرور هر دو ساختار جدول DB و نمایش داده های تعریف طرحواره ، فهمیدم که در واقع یک شاخص در آنجا وجود دارد:
CREATE INDEX users_ssn_hashed_idx ON users (ssn_hash);
اما چرا Postgres با این وجود نادیده گرفتن آن و انجام اسکن های متوالی را انجام داد؟ و علی رغم این واقعیت ، چرا 15 ثانیه پس از اسکن 5000 ردیف طول کشید؟ و چرا همه ردیف ها خیلی سریع (5 میلی ثانیه) انتخاب کردند؟ تنها تفاوت بین این دو استفاده از crypto()
عملکرد در WHERE
بیانیه این مشکل را محدود کرد. بنابراین ، من فهمیدم که زمان بازگشت به اصول اولیه و انجام تحقیقات در مورد ماهیت الگوریتم Bcrypt است.
باکریپت
در اینجا چیزی است که ویکی پدیا در مورد bcrypt مشخص می کند:
ورودی به عملکرد BCRYPT رشته رمز عبور (حداکثر 72 بایت) ، هزینه عددی و مقدار نمک 16 بایت (128 بیتی) است. نمک به طور معمول یک مقدار تصادفی است. عملکرد BCRYPT از این ورودی ها برای محاسبه یک هش 24 بایت (192 بیتی) استفاده می کند. خروجی نهایی عملکرد BCRYPT رشته ای از فرم است:
$2$[cost]$[22 character salt][31 character hash]
به عنوان مثال ، با رمز ورود
abc123xyz
، هزینه12
، و یک نمک تصادفی ، خروجی Bcrypt رشته است$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW \__/\/ \____________________/\_____________________________/ Alg Cost Salt Hash
اگر آن را کمی تقطیر کنیم ، می بینیم که ورودی عملکرد BCRYPT:
- رمز
- هزینه عددی
- نمک (به طور معمول تصادفی)
اگر نگاهی به عملکرد Crypt در عبارت insert کاربر بیندازیم ، این را خواهیم دید crypt($2, gen_salt('bf')
، جایی که SSN به عنوان منتقل می شود $2
بشر بنابراین ، ما یک رمز عبور (SSN) و نمک داریم (gen_salt('bf'))
هزینه عددی از دست رفته است ، اما بیایید نگاهی به خروجی آن بیندازیم gen_salt('bf')
عملکرد. ممکن است شما نیاز به نصب pgcrypt
پسوند توسط CREATE EXTENSION IF NOT EXISTS pgcrypto;
اگر آن را ندارید که بتوانید در کنار هم قرار بگیرید.
SELECT gen_salt('bf');
خروجی است $2a$06$Qkp8j0vDoCuqNCgyOuIRxO
بشر اگر با مثال از ویکی پدیا در بالا مقایسه کنیم ، می توانیم ببینیم که این نمک شامل هزینه درون است (06
).
بیایید اجرا کنیم SELECT gen_salt('bf');
دوباره ما گرفتیم $2a$06$enmGwTijs9cvlsIv9dcQQe
، که با نتیجه قبلی متفاوت است. همانطور که ویکی پدیا به ما هشدار داد
نمک به طور معمول یک مقدار تصادفی است.
و چه اتفاقی می افتد اگر ما نمک تصادفی را به عملکرد رمزنگاری منتقل کنیم؟ درست است ، نتیجه نیز تصادفی خواهد بود. بیایید دوبار بررسی کنیم:
SELECT crypt('0852285111', gen_salt('bf'));
اولین باری که آن را اجرا کردم ، این نتیجه را گرفتم: $2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce
بشر RAN دوم نتیجه متفاوتی کسب کرد (همانطور که پیش بینی کردیم): $2a$06$nbwOka/0ve5RvM71f35jEOMGA5qHaa0H1I3RtkYq/sNIXW.Z90KAm
بشر اگر ادامه دهیم ، نتایج متفاوتی خواهیم گرفت. و این بسیار جالب است ، همانطور که Bcrypt برای استفاده از رمزهای عبور استفاده می شود ، به این معنی که حتی اگر کاربران مختلف رمز عبور یکسانی داشته باشند ، نسخه هشدهی برای آنها متفاوت خواهد بود – بسیار مرتب!
اما اگر نتایج تصادفی باشد ، چگونه می توانیم هش را در DB با ضبط متن ساده که به عنوان ورودی کاربر دریافت می کنیم ، بررسی کنیم؟ خوب ، همانطور که می توانید حدس بزنید ، ما باید همان نمک ورودی را منتقل کنیم. اما نمک را از کجا می گیریم؟ ویکی پدیا قبلاً پاسخی به ما داده است: نمک بخشی از خروجی هشده است. بیایید با کپی کردن 29 بار اول از خروجی که در بالا دریافت کردیم ، آن را امتحان کنیم: $2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce
-> $2a$06$DQqtgCtvY9GkT3uHpShehu
و بررسی کنید که آیا این کمک می کند:
SELECT '$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce' = crypt('0852285111', '$2a$06$DQqtgCtvY9GkT3uHpShehu');
نتیجه این است true
بشر عالی! اما یک دقیقه صبر کنید: آیا ما باید هر بار نمک و دوستان را به صورت دستی استخراج کنیم؟ بیایید نگاهی به پرس و جو مورد استفاده برای انتخاب کاربر توسط SSN از DB بیندازیم تا به دنبال برخی از نکات بگردیم:
SELECT id, user_info FROM users WHERE ssn_hash = crypt($1, ssn_hash)
کجا $1
مقدار SSN است این کمی متفاوت است زیرا به جای فقط نمک ، ما این بار کل مقدار هش را به عملکرد Crypt منتقل می کنیم. بیایید آن را امتحان کنیم:
SELECT '$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce' = crypt('0852285111', '$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce');
هنوز true
بشر چطور؟ پاسخ ساده است: از آنجا که نمک (با پیشوند) طول مشخصی از 29 دارد ، Postgres آن را برای ما بازیابی می کند و بقیه آن را رها می کند.
زیبایی Postgres (که یکی از ابزارهای مورد علاقه من در آنجا است) این واقعیت است که منبع باز است. بنابراین ، بیایید اوج و کد منبع آن را بگیریم تا اطمینان حاصل کنیم که فرضیات ما صحیح است.
bcrypt در کد منبع postgres
در اینجا آینه مخزن رسمی PostgreSQL GIT است. بیایید پیدا کنیم pgcrypto
پرونده ای که مطابق با برنامه افزودنی است که ما نصب کردیم تا بتوانیم توابع رمزنگاری را اجرا کنیم. اینجاست. این قطعه کد هنگام انجام کار ایجاد می شود crypt(...)
از طریق SQL:
/* SQL function: pg_crypt(psw:text, salt:text) returns text */
PG_FUNCTION_INFO_V1(pg_crypt);
Datum
pg_crypt(PG_FUNCTION_ARGS)
{
text *arg0 = PG_GETARG_TEXT_PP(0);
text *arg1 = PG_GETARG_TEXT_PP(1);
char *buf0,
*buf1,
*cres,
*resbuf;
text *res;
buf0 = text_to_cstring(arg0);
buf1 = text_to_cstring(arg1);
resbuf = palloc0(PX_MAX_CRYPT);
cres = px_crypt(buf0, buf1, resbuf, PX_MAX_CRYPT);
pfree(buf0);
pfree(buf1);
if (cres == NULL)
ereport(ERROR,
(errcode(ERRCODE_EXTERNAL_ROUTINE_INVOCATION_EXCEPTION),
errmsg("crypt(3) returned NULL")));
res = cstring_to_text(cres);
pfree(resbuf);
PG_FREE_IF_COPY(arg0, 0);
PG_FREE_IF_COPY(arg1, 1);
PG_RETURN_TEXT_P(res);
}
اگر به داخل پرش کنیم pg_crypt
در این خط cres = px_crypt(buf0, buf1, resbuf, PX_MAX_CRYPT);
، ما در نهایت به داخل px-crypt.c
پرونده در اینجا:
char *
px_crypt(const char *psw, const char *salt, char *buf, unsigned len)
{
const struct px_crypt_algo *c;
CheckBuiltinCryptoMode();
for (c = px_crypt_list; c->id; c++)
{
if (!c->id_len)
break;
if (strncmp(salt, c->id, c->id_len) == 0)
break;
}
if (c->crypt == NULL)
return NULL;
return c->crypt(psw, salt, buf, len);
}
همانطور که می بینیم ، این کد از طریق px_crypt_list
در حالی که سعی در یافتن مسابقه برای ارائه شده است salt
(در مورد ما ، این کل رشته hashed است) با مقایسه id_len
تعداد شخصیت های اول با چیزی به نام id
بشر در اینجا دکتر برای strncmp
اگر می خواهید اطلاعات بیشتری کسب کنید ، عملکرد را انجام دهید.
بیایید ببینیم چگونه px_crypt_list
به نظر می رسد ، همانطور که ممکن است ماهیت آن را برای ما توضیح دهد id_len
وت id
پارامترها
struct px_crypt_algo
{
char *id;
unsigned id_len;
char *(*crypt) (const char *psw, const char *salt,
char *buf, unsigned len);
};
static const struct px_crypt_algo
px_crypt_list[] = {
{"$2a$", 4, run_crypt_bf},
{"$2x$", 4, run_crypt_bf},
{"$2$", 3, NULL}, /* N/A */
{"$1$", 3, run_crypt_md5},
{"_", 1, run_crypt_des},
{"", 0, run_crypt_des},
{NULL, 0, NULL}
};
آه ، بنابراین id
پیشوند نمکی است که ویکی پدیا نامیده می شود alg
($2a$
در مورد ما) ، و id_len
طول آن پیشوند است. اکنون این حس زیادی ایجاد می کند. و run_crypt_bf
عملکرد واقعی است که در مورد ما مورد استفاده قرار می گیرد. خوب ، بیایید نگاهی به آن بیندازیم:
static char *
run_crypt_bf(const char *psw, const char *salt,
char *buf, unsigned len)
{
char *res;
res = _crypt_blowfish_rn(psw, salt, buf, len);
return res;
}
و بیایید با پریدن به داخل _crypt_blowfish_rn
که ما را به یک پرونده جدید سوق می دهد – crypt-blowfish.c
بشر من تمام بدن آن عملکرد را در اینجا ارسال نخواهم کرد ، زیرا حدود 200 خط طول دارد. با این حال ، این یک بخش جالب برای ما است:
/*
* Blowfish salt value must be formatted as follows: "$2a$" or "$2x$", a
* two digit cost parameter, "$", and 22 digits from the alphabet
* "./0-9A-Za-z". -- from the PHP crypt docs. Apparently we enforce a few
* more restrictions on the count in the salt as well.
*/
if (strlen(setting) < 29)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("invalid salt")));
کجا settings
است salt
بشر ما می توانیم شماره 29 را در اینجا ببینیم ، که زنگ می زند ، اینطور نیست؟ این دقیقاً طول پیشوند نمکی است که در یکی از مثالهای بالا از آن استفاده کردیم. همچنین ، لطفاً توجه داشته باشید که این اعتبارسنجی فقط بررسی می کند که طول ورودی کمتر از 29 نیست ، اما اهمیتی نمی دهد که بالاتر از حد مجاز باشد. این به ما اشاره می کند که کد زیر باید به نوعی آن را اداره کند:
if (setting[0] != '$' ||
setting[1] != '2' ||
(setting[2] != 'a' && setting[2] != 'x') ||
setting[3] != '$' ||
setting[4] < '0' || setting[4] > '3' ||
setting[5] < '0' || setting[5] > '9' ||
(setting[4] == '3' && setting[5] > '1') ||
setting[6] != '$')
{
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("invalid salt")));
}
count = (BF_word) 1 << ((setting[4] - '0') * 10 + (setting[5] - '0'));
if (count < 16 || BF_decode(data.binary.salt, &setting[7], 16))
{
px_memset(data.binary.salt, 0, sizeof(data.binary.salt));
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("invalid salt")));
}
به این قطعه نگاهی بیندازید: BF_decode(data.binary.salt, &setting[7], 16)
بشر آیا نمونه ویکی پدیا را به خاطر می آورید:
$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW \__/\/ \____________________/\_____________________________/ Alg Cost Salt Hash
؟
همانطور که می بینیم ، نمک واقعی از هشتم کاراکتر شروع می شود (به همین دلیل setting[7]
) ، و &setting[7]
به این معنی است که تمام عناصر شروع شده از 8 در اینجا قطعه قطعه می شوند. در BF_decode
عملکرد مانند این است:
static int
BF_decode(BF_word *dst, const char *src, int size)
{
unsigned char *dptr = (unsigned char *) dst;
unsigned char *end = dptr + size;
const unsigned char *sptr = (const unsigned char *) src;
unsigned int tmp,
c1,
c2,
c3,
c4;
do
{
BF_safe_atoi64(c1, *sptr++);
BF_safe_atoi64(c2, *sptr++);
*dptr++ = (c1 << 2) | ((c2 & 0x30) >> 4);
if (dptr >= end)
break;
BF_safe_atoi64(c3, *sptr++);
*dptr++ = ((c2 & 0x0F) << 4) | ((c3 & 0x3C) >> 2);
if (dptr >= end)
break;
BF_safe_atoi64(c4, *sptr++);
*dptr++ = ((c3 & 0x03) << 6) | c4;
} while (dptr < end);
return 0;
}
در حالی که منطق دستکاری کمی در اینجا وجود دارد ، که من جرات توضیح آن را ندارم ، اما آنچه در اینجا مهم است این است dptr < end
اطمینان حاصل خواهد کرد که نتیجه 16 بایت خواهد بود. اما نمک باید 22 باشد؟
در Bcrypt ، در حالی که نمک در واقع به عنوان 22 کاراکتر در قالب Base64 رمزگذاری شده است ، در واقع به 16 بایت داده های خام رمزگذاری می شود. هنگام رمزگذاری داده های باینری به Base64:
- هر 3 بایت از داده های باینری در Base64 به 4 کاراکتر تبدیل می شود
- این امر به این دلیل است که هر کاراکتر Base64 6 بیت را نشان می دهد (2^6 = 64 امکانات)
- بنابراین 3 بایت (24 بیت) نقشه داده های باینری به 4 کاراکتر Base64 (4 * 6 = 24 بیت)
برای نمک bcrypt:
- 16 بایت داده های خام = 128 بیت
- هنگامی که در Base64 رمزگذاری می شود: ⌈ (16 * 8) / 6⌉ = ⌈128 / 6⌉ = 22 نویسه
- از عملکرد سقف استفاده می شود زیرا ما باید تا شخصیت کامل BASE64 را جمع کنیم
به این ترتیب ، می توانیم ببینیم که می توان کل رشته هشده را به عنوان ورودی نمک Postgres منتقل کرد crypt()
عملکرد. خوب ، این سرگرم کننده بود ، اما بیایید به بحث Bcrypt خود برگردیم.
اکنون چه می دانیم؟
قبل از اینکه آنچه را که اکنون می دانیم خلاصه کنیم ، بگذارید یک نقل قول دیگر درباره BCrypt از مقاله ویکی پدیا برای شما ارائه دهم:
سپس تعدادی دور وجود دارد که در آن از الگوریتم کلید کلیدنگ Blowfish استفاده می شود ، با استفاده از جایگزین نمک و رمز عبور به عنوان کلید ، هر دور با حالت زیر کلید از دور قبلی شروع می شود. از نظر تئوری ، این هیچ قوی تر از برنامه کلید استاندارد Blowfish نیست ، اما تعداد دور بازگرداندن قابل تنظیم است. این بنابراین فرآیند می تواند به طور خودسرانه کند شود، که به جلوگیری از حملات بی رحمانه به هش یا نمک کمک می کند.
بنابراین ، الگوریتم Bcrypt با طراحی نسبتاً کند است ، زیرا محافظت از حملات بی رحمانه است.
بیایید لیستی از حقایق شناخته شده در مورد الگوریتم BCRYPT ایجاد کنیم:
- برای تولید هش به نمک احتیاج دارد
- در حالت ایده آل ، نمک باید تصادفی باشد
- نمک تصادفی منجر به نتایج مختلف برای همان ورودی هش می شود
- برای مقایسه رشته هشده موجود با یک ساده ، نمک اصلی باید به عنوان ورودی عملکرد رمزنگاری منتقل شود
- Postgres اجازه می دهد تا رشته هشده را به عنوان نمک عبور دهید و از استخراج قسمت نمک آن برای انجام هشویی مراقبت می کند
با تمام این اطلاعات ، در نهایت سعی می کنیم معمای عملکرد ضعیف پرس و جو را برای واکشی کاربر توسط SSN حل کنیم.
شاخص Postgres و bcrypt
بنابراین ، چرا Postgres علی رغم شاخص آن ستون ، اسکن های متوالی را انجام می دهد؟ بیایید نگاهی دیگر به پرس و جو مورد نظر خود بیندازیم:
SELECT *
FROM users
WHERE ssn_hash = crypt('0852285111', ssn_hash);
همانطور که از قبل می دانیم ، برای بازگرداندن هش ، crypto()
عملکرد باید نمک اصلی را به عنوان ورودی دریافت کند. اما ، همانطور که می بینید ، تنها چیز شناخته شده وقتی این پرس و جو را اجرا می کنیم ، مقدار SSN ساده است. این بدان معنی است که تنها گزینه ممکن Postgres انجام موارد زیر است:
- تکرار بیش از هر ردیف موجود در
users
جدول - واگذار کردن
ssn_hash
ارزش - SSN دشت را عبور داده و واکشی کنید
ssn_hash
ارزش (به عنوان نمک) بهcrypt()
عملکرد و محاسبه هش - نتیجه را با واکشی مقایسه کنید
ssn_hash
ارزش - اگر مطابقت داشته باشیم ، آنچه را که به دنبالش هستیم پیدا کردیم ، در غیر این صورت ، باید به مورد بعدی و غیره برویم
از آنجا که الگوریتم شرح داده شده نیاز به تکرار بیش از همه موارد موجود دارد ، در حالی که Postgres به هیچ وجه به شاخص ها اعتماد نمی کند ، منطقی است. اگر برای مقایسه ssn_hash
با رشته ساده به جای crypt()
عملکردی مانند این:
EXPLAIN ANALYSE
SELECT *
FROM users
WHERE ssn_hash = '$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce';
می بینیم که این بار Postgres از فهرست استفاده می کند:
Index Scan using users_ssn_hashed_idx on users (cost=0.28..8.30 rows=1 width=138) (actual time=0.015..0.015 rows=0 loops=1)
Index Cond: (ssn_hash="$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce"::text)
Planning Time: 0.448 ms
Execution Time: 0.033 ms
همچنین ، به یاد داشته باشید که ما ذکر کردیم که الگوریتم Bcrypt نسبتاً کند است؟ اما اگر 5000 را کند کنیم (تعداد سوابق جدول که داریم) چند برابر کنیم؟ این علت واقعی تخریب عملکرد است که ما در این مدت تحقیق کرده ایم!
ما به سادگی می توانیم با اجرای آن تأیید کنیم:
EXPLAIN ANALYSE
SELECT *
FROM users;
خروجی:
Seq Scan on users (cost=0.00..157.00 rows=5000 width=138) (actual time=0.009..0.441 rows=5001 loops=1)
Planning Time: 0.094 ms
Execution Time: 0.641 ms
همانطور که می بینید ، حتی اگر پرس و جو در تمام ردیف ها تکرار شده باشد ، نیازی به انجام هاش کردن bcrypt اصلاً ندارد ، زمان اجرای آن بسیار کم است.
خوب ، بنابراین ما اکنون می دانیم که Bcrypt کسی است که برای عملکرد ضعیف مقصر است. اما آیا این بدان معنی است که Bcrypt فقط یک الگوریتم بد عملکردی است؟
bcrypt: از کجا استفاده کنید و از کجا استفاده نکنید
مشابه هر ابزار دیگر ، BCRYPT مورد استفاده خود را دارد: رمزهای عبور هشدار دهنده. این بدان معنی است که مورد استفاده مورد انتظار برای آن با موردی که در بالا داریم متفاوت است ، زیرا نباید توسط آن جستجو کنیم. بگذارید سناریویی را که می توان از آن استفاده کرد نشان دهم:
CREATE TABLE user_credentials
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE INDEX user_credentials_login_idx ON user_credentials (login);
و بیایید چند مورد را وارد کنیم:
func main() {
dbUrl := "postgres://adminU:adminP@localhost:5432/n0rdyblog"
dbPool, err := pgxpool.New(context.Background(), dbUrl)
if err != nil {
panic(err)
}
defer dbPool.Close()
numOfUsers := 5000
for i := 0; i < numOfUsers; i++ {
login := randomString(10)
password := randomString(20)
dbPool.Exec(context.Background(), "INSERT INTO user_credentials(login, password_hash) VALUES($1, crypt($2, gen_salt('bf')))", login, password)
}
}
func randomString(length int) string {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
panic(err)
}
return base64.URLEncoding.EncodeToString(bytes)[:length]
}
روشی که اکنون قرار است نمایش داده شود این است:
- هنگامی که کاربر سعی می کند وارد سیستم شود ، آنها اعتبار خود را به ما منتقل می کنند: ورود به سیستم و رمز عبور
- در اینجا پرس و جو که ما برای تأیید اعتبار ارائه شده صحیح اجرا می کنیم:
SELECT *
FROM user_credentials
WHERE login = 'ChCpOSGOCx'
AND password_hash = crypt('ygEUnWIFIBoJ7ZQQqQ3j', password_hash);
حتی اگر user_credentials
جدول دارای 5000 مورد است ، این پرس و جو بسیار سریع است ، بیایید ببینیم چقدر سریع:
EXPLAIN ANALYSE
SELECT *
FROM user_credentials
WHERE login = 'ChCpOSGOCx'
AND password_hash = crypt('ygEUnWIFIBoJ7ZQQqQ3j', password_hash);
نتایج:
Index Scan using user_credentials_login_idx on user_credentials (cost=0.28..8.31 rows=1 width=88) (actual time=4.502..4.513 rows=1 loops=1)
Index Cond: (login = 'ChCpOSGOCx'::text)
" Filter: (password_hash = crypt('ygEUnWIFIBoJ7ZQQqQ3j'::text, password_hash))"
Planning Time: 0.067 ms
Execution Time: 4.542 ms
تفاوت کلیدی بین این سناریو و موردی که با SSNS داشتیم ، این است که در اینجا Postgres به دلیل استفاده از فهرست بندی ها می تواند از فهرست بندی استفاده کند WHERE login = 'ChCpOSGOCx'
بخشی ، و سپس آن را فقط برای ردیف برگشت داده شده توسط شاخص محاسبه می کند. از آنجا که ، ما login
ستون منحصر به فرد است ، به این معنی است که محاسبه bcrypt فقط 1 بار اتفاق می افتد ، بنابراین O(1)
به جای O(n)
بشر
اینگونه است که قرار است BCRYPT بدون پرداخت هزینه های با کارایی بسیار بالا ، از امنیت خود بهره ببرد.
خوب ، اما چگونه می توانیم مشکل اصلی را حل کنیم؟
بیایید عملکرد را برطرف کنیم
البته با جابجایی به الگوریتم سریعتر دیگر با نتیجه قابل پیش بینی مانند SHA-256 ، یک فنی آشکار وجود دارد و مسئله عملکرد حل می شود. اما 2 نکته وجود دارد که ارزش بحث و گفتگو دارد:
- از آنجا که ورودی SHA-256 (یا هر الگوریتم قطعی دیگر) SSN خواهد بود ، که دارای فرمت شناخته شده 10 رقم است ، به این معنی است که هش با حمله بیرحمانه به راحتی می تواند به عنوان 10^10 (به راحتی می تواند ترک شود. 10 میلیارد) گزینه های احتمالی SSN چیزی است که تولید آن برای رایانه های مدرن آسان است
در اینجا کد ساده و بی پروا برای تولید تمام مقادیر هش SSN ممکن است:
const (
ssnLength = 10
)
func main() {
start := time.Now()
// initializes the buffer with ASCII zeros
current := make([]byte, ssnLength)
// generates all possible combinations
generateAllPossibleSSNs(current, 0)
duration := time.Since(start)
fmt.Printf("Completed in %v\n", duration)
}
// generateAllPossibleSSNs generates all possible SSNs and hashes them
func generateAllPossibleSSNs(current []byte, position int) {
if position == ssnLength {
sha256.Sum256(current)
return
}
// converts 0-9 directly to ASCII bytes ('0' = 48 in ASCII)
for i := byte(48); i < byte(58); i++ {
current[position] = i
generateAllPossibleSSNs(current, position+1)
}
}
30 دقیقه طول کشید تا آن را در Macbook Pro 2019 من اجرا کنید ، که قطعاً یک دستگاه منسوخ از سال 2025 است. اگر مورد استفاده دیگری داشتیم ، به عنوان مثال ، ما نیاز به ذخیره هش از کلیدهای API که از نوع UUID V4 هستند ، بنابراین تصادفی ، SHA-256 می تواند انتخاب خوبی برای چنین سناریویی باشد.
این ما را به نکته دیگری می رساند:
- چرا ما در وهله اول SSN ها را هش می دهیم؟
هر وقت من دیگر مهندسین نرم افزار را راهنمایی می کنم ، همیشه می گویم که نوشتن کد بخشی آسان از کار ما است و ما باید به جای پرش به حالت برنامه نویسی ، ابتدا هر مشکل و نیاز را تأیید کنیم. در اینجا: ما از هش کردن SSN ها چه چیزی به دست می آوریم؟ آیا این یک الزام حریم خصوصی است یا فقط یک فرض توسط شخصی که “SSN ها حساس هستند ، بنابراین ما نباید مقادیر ساده را برای آنها ذخیره کنیم”؟ آیا این احتمال وجود دارد که ما آنها را در جدول / پایگاه داده دیگری در سیستم خود به روش ساده ذخیره کنیم؟ اگر اینگونه نباشد ، حریم خصوصی و الزامات قانونی برای ارزشهای هشدار چیست؟ همانطور که می بینیم ، بدون نمک ، SSN ها به راحتی حوزه ای می کنند ، اما نمک تصادفی منجر به عملکرد ضعیف می شود.
در بعضی موارد ، به نظر می رسد که ، در واقع ، راه حل به سادگی مانند هش کردن داده ها ، اگر خیلی حساس تلقی نشود. در موارد دیگر ، ما ممکن است نیاز به یک نمک رایج برای همه هش ها داشته باشیم ، که با استفاده از قانون شست گاندالف ذخیره خواهیم کرد:
به جای باز نگه داشتن/در هش مانند مثال قبلی ما.
در این حالت ، اگر نمک رایج باشد ، حتی می توانیم در Bcrypt بمانیم ، زیرا پرس و جو با نمک شناخته شده از فهرست بندی استفاده می کند:
EXPLAIN ANALYSE
SELECT *
FROM users
WHERE ssn_hash = crypt('0852285111', '$2a$06$DQqtgCtvY9GkT3uHpShehu');
نتیجه این است:
Index Scan using users_ssn_hashed_idx on users (cost=0.28..8.30 rows=1 width=138) (actual time=0.038..0.038 rows=0 loops=1)
Index Cond: (ssn_hash="$2a$06$DQqtgCtvY9GkT3uHpShehuxb3eHD50H6XNxzEyoxZkuDjEt87/6Ce"::text)
Planning Time: 3.462 ms
Execution Time: 0.118 ms
همانطور که ممکن است به یاد داشته باشید ، ما با 15 ثانیه در هر پرس و جو شروع کردیم و اکنون به 3 میلی ثانیه رسیده ایم که 5000 برابر سریعتر است – یک دستاورد هیجان انگیز.
با این وجود ، اگر یک نمک مشترک داده شود ، چیزی سریعتر مانند SHA-256 ، SHA-3 ، Blake2 یا Blake3 ممکن است گزینه های بهتری باشد. البته ، این نیاز به معیار ، مشاوره با تیم امنیتی شما و بررسی پشتیبانی از پسران برای پرونده خاص شما قبل از تصمیم گیری دارد.
و این همان است ، تیم و کاربران خوشحال هستند ، سیستم دوباره سالم است. ما روز را نجات دادیم و یک یا دو مورد در مورد الگوریتم BCRYPT آموختیم.
این کاملاً سواری بود ، بنابراین از خواندن پست من بسیار سپاسگزارم. من واقعاً امیدوارم که این مفید باشد ، و من شما را در موارد بعدی خود می بینم ، به زودی بیشتر. لذت ببرید! =)
اگر پست را دوست داشتید: