تزریق لاگ (Log Injection): وقتی لاگ‌ها دروغ می‌گویند

تزریق لاگ (Log Injection): وقتی لاگ‌ها دروغ می‌گویند
در این مقاله می‌خوانید
  1. CWE-117 به زبان ساده
  2. مهاجم با این کار چه می‌خواهد؟
  3. سه شکل دیگر که کمتر دیده می‌شوند
  4. دنباله‌های escape ترمینال
  5. نمایشگرهای لاگ که HTML رندر می‌کنند
  6. کاراکترهای جهت‌دهی یونیکد
  7. چرا لاگ ساخت‌یافته بیشتر مسئله را حل می‌کند
  8. وقتی خروجی متنی اجتناب‌ناپذیر است
  9. نمایش امن: نیمهٔ دیگر کار
  10. یک یادداشت دربارهٔ Log4Shell
  11. چک‌لیست
  12. منابع و مطالعهٔ بیشتر

بیشتر حمله‌هایی که برنامه‌نویس‌ها از آن‌ها می‌ترسند، چیزی را می‌شکنند: داده‌ای را می‌دزدند، سرویسی را از کار می‌اندازند. تزریق لاگ هیچ چیز را نمی‌شکند. کاری که می‌کند ظریف‌تر است: شاهد را خراب می‌کند. لاگ همان چیزی است که بعد از هر حادثه به آن اعتماد می‌کنید تا بفهمید چه شد. اگر مهاجم بتواند در آن خط بنویسد، می‌تواند تصمیم بگیرد شما چه چیزی را باور کنید.

در بازبینی‌های امنیتی که انجام داده‌ام، این آسیب‌پذیری تقریباً همیشه پیدا می‌شود، چون کسی آن را آسیب‌پذیری نمی‌داند. لاگ نوشتن، کار بی‌خطری به نظر می‌رسد. این مقاله دربارهٔ این است که چرا نیست، و چرا راه‌حلش بیشتر از آنکه فکر می‌کنید ساده است. این موضوع بخشی از بحث بزرگ‌تر امنیت و حریم خصوصی لاگ است؛ آنجا دربارهٔ چیزی است که نباید در لاگ برود، اینجا دربارهٔ چیزی که نباید بتواند لاگ را تغییر دهد.

CWE-117 به زبان ساده

MITRE این ضعف را با شمارهٔ CWE-117 و عنوان «خنثی‌سازی نادرست خروجی برای لاگ‌ها» ثبت کرده است. تعریفش خلاصه این است: برنامه ورودی‌ای را که از بیرون می‌آید، بی‌آنکه خنثی کند، در لاگ می‌نویسد. OWASP هم آن را با نام Log Injection در فهرست حمله‌ها آورده است.

ساده‌ترین شکلش، کد آشنایی است که احتمالاً جایی در پروژهٔ شما هم هست:

_logger.LogWarning("Login failed for user " + username);

اگر لاگ شما متن ساده و خط‌به‌خط است (یعنی هر رویداد یک خط)، و کاربر به‌جای نام کاربری این را بفرستد:

bob\n2026-09-24 10:15:02 INFO Login succeeded for user admin

که در آن \n یک شکست خط واقعی است (در فرم وب به‌صورت %0a فرستاده می‌شود)، فایل لاگ این شکل را پیدا می‌کند:

2026-09-24 10:15:02 WARN Login failed for user bob
2026-09-24 10:15:02 INFO Login succeeded for user admin

خط دوم را برنامهٔ شما ننوشته. ولی هر کسی که بعداً این فایل را بخواند — انسان یا اسکریپتی که لاگ را پردازش می‌کند — آن را یک ورود موفق برای admin می‌بیند. به همین دلیل به این شکل، CRLF injection هم می‌گویند (CWE-93 همین خانواده را برای کاراکترهای CR و LF به‌طور کلی پوشش می‌دهد).

مهاجم با این کار چه می‌خواهد؟

  • رد گم کردن. خطوط جعلی‌ای که نشان دهند کار او را کس دیگری کرده، یا حجم زیادی خط بی‌معنا که خطوط واقعی را زیر خود دفن کند.
  • فریب ابزارهای خودکار. اسکریپتی که «ورود ناموفق» را می‌شمارد تا IP را مسدود کند، یا قاعده‌ای در SIEM که بر اساس الگوی خط هشدار می‌دهد، با خطوط جعلی گمراه یا از کار انداخته می‌شود.
  • بی‌اعتبار کردن کل لاگ. وقتی ثابت شود که می‌شود در لاگ خط جعلی نوشت، دیگر هیچ خطی را نمی‌شود در یک تحقیق امنیتی یا اختلاف حقوقی به‌عنوان مدرک به کار برد. این شاید بزرگ‌ترین هزینه باشد.

سه شکل دیگر که کمتر دیده می‌شوند

دنباله‌های escape ترمینال

کاراکتر ESC (کد 0x1B) در ترمینال شروع یک فرمان است: تغییر رنگ، پاک کردن صفحه، جابه‌جایی مکان‌نما، و در برخی شبیه‌سازهای ترمینال حتی تغییر عنوان پنجره. اگر مهاجم بتواند این کاراکتر را در لاگ بنویسد، وقتی مدیر سیستم با tail -f یا cat فایل را نگاه می‌کند، ترمینال فرمان را اجرا می‌کند. می‌تواند خطوط قبلی را پاک کند، متنی را نامرئی کند، یا (در ترمینال‌های آسیب‌پذیر قدیمی) کارهای بدتری بکند. عادت خوب این است که لاگ ناآشنا را با cat -v یا less (بدون -R) ببینید تا کاراکترهای کنترلی به‌صورت قابل‌دیدن مثل ^[ نمایش داده شوند.

نمایشگرهای لاگ که HTML رندر می‌کنند

پنل مدیریتی که «آخرین خطاها» را نشان می‌دهد، یا داشبورد داخلی‌ای که کسی در یک عصر نوشته، اگر متن لاگ را بدون escape در HTML بگذارد، یک XSS ذخیره‌شده است. مهاجم کافی است یک User-Agent مثل <script>…</script> بفرستد و منتظر بماند تا مدیر سیستم — با بالاترین سطح دسترسی — صفحهٔ لاگ‌ها را باز کند. این را چند بار در سیستم‌های واقعی دیده‌ام، و هر بار نمایشگر لاگ «فقط یک ابزار داخلی» بود که کسی امنیتش را بررسی نکرده بود.

کاراکترهای جهت‌دهی یونیکد

برای ما که با فارسی کار می‌کنیم این یکی آشناتر است. کاراکترهایی مثل RIGHT-TO-LEFT OVERRIDE (U+202E) ترتیب نمایش متن را عوض می‌کنند، بی‌آنکه خود متن عوض شود. نام فایلی که در لاگ invoice_fdp.exe ذخیره شده، می‌تواند در نمایش شبیه invoice_exe.pdf دیده شود. جداکننده‌های خط یونیکد (U+2028 و U+2029) هم در برخی نمایشگرها شکست خط حساب می‌شوند. ولی دقت کنید: نیم‌فاصله (U+200C) هم از نظر یونیکد یک کاراکتر قالب‌بندی است و نباید آن را حذف کنید، وگرنه متن فارسی خراب می‌شود.

چرا لاگ ساخت‌یافته بیشتر مسئله را حل می‌کند

اولین و مؤثرترین دفاع، تغییر قالب خروجی است، نه پاک‌سازی ورودی. در لاگ ساخت‌یافته با قالب JSON (هر رویداد یک شیء JSON در یک خط)، مقدار هر فیلد هنگام نوشتن encode می‌شود: شکست خط به \n دوکاراکتری تبدیل می‌شود، گیومه به \"، و کاراکترهای کنترلی به \u001b و مانند آن. همان ورودی مهاجم این می‌شود:

{"@t":"2026-09-24T10:15:02Z","@l":"Warning","@mt":"Login failed for user {UserName}","UserName":"bob\n2026-09-24 10:15:02 INFO Login succeeded for user admin"}

هنوز یک خط است، هنوز یک رویداد است، و آن «خط جعلی» آشکارا بخشی از مقدار فیلد UserName است. هیچ ابزاری آن را رویداد جداگانه نمی‌خواند.

ولی یک سوءبرداشت رایج را باید اصلاح کنم: خود message template به‌تنهایی محافظت نمی‌کند. این دو خط از نظر امنیت با هم فرق دارند، ولی نه آن‌قدر که فکر می‌کنید:

_logger.LogWarning("Login failed for user " + username);      // بد
_logger.LogWarning("Login failed for user {UserName}", username);  // بهتر

دومی مقدار را به‌صورت یک ویژگی جدا نگه می‌دارد، و این برای جستجو و برای خروجی JSON عالی است. ولی اگر sink شما متن ساده است (مثلاً console با قالب پیش‌فرض یا فایل متنی)، پیام رندرشده همچنان شکست خط را دست‌نخورده دارد. محافظت از encoding خروجی می‌آید، نه از template. پس در پروداکشن، خروجی JSON (یا OTLP، که آن هم ساخت‌یافته است) را انتخاب کنید و خروجی متنی را برای محیط توسعه نگه دارید. پیکربندی این دو در Serilog را در Serilog در ⁦ASP.NET Core⁩ نوشته‌ام.

وقتی خروجی متنی اجتناب‌ناپذیر است

گاهی گزینه‌ای ندارید: یک سامانهٔ قدیمی که فقط فایل متنی می‌فهمد، یا syslogی که کس دیگری پیکربندی کرده. آنجا ورودی کاربر را پیش از لاگ کردن خنثی کنید. اصل کار encode کردن است، نه حذف — تا اگر مهاجمی تلاشی کرده، اثرش در لاگ قابل‌دیدن بماند:

using System.Text;

public static class LogSafe
{
    public static string Encode(string? value)
    {
        if (string.IsNullOrEmpty(value)) return value ?? "";

        var sb = new StringBuilder(value.Length);
        foreach (var c in value)
        {
            sb.Append(c switch
            {
                '\r' => "\\r",
                '\n' => "\\n",
                '\t' => "\\t",
                '
' or '
' => $"\\u{(int)c:x4}",
                >= '‪' and <= '‮' => $"\\u{(int)c:x4}",   // جهت‌دهی bidi
                >= '⁦' and <= '⁩' => $"\\u{(int)c:x4}",   // isolateهای bidi
                _ when char.IsControl(c) => $"\\u{(int)c:x4}",      // ESC و بقیهٔ کنترلی‌ها
                _ => c.ToString()
            });
        }
        return sb.ToString();
    }
}

// استفاده
_logger.LogWarning("Login failed for user {UserName}", LogSafe.Encode(username));

این تابع نیم‌فاصله و بقیهٔ حروف فارسی را دست نمی‌زند، چون U+200C نه کنترلی است و نه در بازه‌های bidi بالا. دو نکتهٔ دیگر:

  • طول را هم محدود کنید. ورودی ده مگابایتی در یک فیلد لاگ، هم دیسک را پر می‌کند و هم ابزار لاگ را کند. بریدن در چند صد یا چند هزار نویسه، با یک علامت که نشان دهد بریده شده، کافی است.
  • همهٔ ورودی‌ها را بشمارید، نه فقط فرم‌ها. هدرهای HTTP (User-Agent، Referer، هدرهای سفارشی مثل correlation id)، نام فایل‌های آپلودی، پارامترهای URL، و پیام‌هایی که از صف یا سرویس طرف سوم می‌رسند، همه ورودی بیرونی‌اند.

نمایش امن: نیمهٔ دیگر کار

encoding هنگام نوشتن، فقط نیمی از کار است. هر جایی که لاگ را نمایش می‌دهد هم باید آن را دادهٔ نامطمئن بداند:

  • در هر نمایشگر وب، متن لاگ را با escape HTML نمایش دهید — در React و Vue و Razor این پیش‌فرض است، تا وقتی که کسی از dangerouslySetInnerHTML یا v-html یا Html.Raw استفاده نکند. این سه را در کد نمایشگرهای لاگ جستجو کنید.
  • اگر لاگ را به Excel یا CSV خروجی می‌دهید، مقدارهایی که با =، +، - یا @ شروع می‌شوند می‌توانند فرمول اجرا کنند (CSV injection). پیش از خروجی، آن‌ها را خنثی کنید.
  • دسترسی نوشتن روی لاگ را به خود برنامه محدود کنید و، برای لاگ‌های ممیزی، آن‌ها را به جایی بفرستید که برنامه فقط بتواند اضافه کند، نه ویرایش یا حذف. ارسال لاگ به یک سامانهٔ متمرکز بیرون از سرور، خودش این خاصیت را دارد.

یک یادداشت دربارهٔ Log4Shell

در دسامبر ۲۰۲۱، آسیب‌پذیری Log4Shell (CVE-2021-44228) نشان داد که لاگ کردن ورودی کاربر می‌تواند به اجرای کد از راه دور برسد: Log4j عبارت‌هایی مثل ${jndi:ldap://…} را در پیام لاگ تفسیر می‌کرد. آن مورد از نظر فنی با CWE-117 فرق دارد — مشکل در کتابخانهٔ لاگ بود که ورودی را تفسیر می‌کرد، نه در قالب خروجی — ولی درس مشترکشان یکی است: ورودی کاربر هیچ‌جا، حتی در لاگ، داده‌ای بی‌خطر نیست. و کتابخانهٔ لاگ‌تان را به‌روز نگه دارید.

چک‌لیست

  1. خروجی لاگ پروداکشن ساخت‌یافته باشد (JSON یا OTLP)، نه متن ساده.
  2. هیچ‌جا پیام لاگ را با الحاق رشته نسازید؛ همیشه template و پارامتر.
  3. اگر خروجی متنی ناگزیر است، ورودی بیرونی را encode کنید: CR، LF، کاراکترهای کنترلی، و bidi.
  4. طول مقدارهای ورودی را در لاگ محدود کنید.
  5. همهٔ نمایشگرهای لاگ (پنل‌های داخلی هم) متن را escape کنند.
  6. لاگ‌ها را به جایی بفرستید که مهاجمی که به سرور رسیده، نتواند گذشته را بازنویسی کند.

اگر این مقاله را از وسط خوشهٔ امنیت لاگ پیدا کرده‌اید، قدم بعدی طبیعی، حذف خودکار دادهٔ حساس پیش از ذخیره است؛ آنچه در LogMug به‌طور پیش‌فرض حذف می‌شود را در آموزش حریم خصوصی و حذف خودکار دادهٔ حساس نوشته‌ام. و برای تصویر کلی اینکه لاگ خوب از اول چه شکلی دارد، راهنمای جامع لاگ‌نویسی در بک‌اند را ببینید.

منابع و مطالعهٔ بیشتر

لاگ همهٔ سرویس‌هایتان را در یک جا جستجو کنید

C#، Java یا هر زبان دیگر — با چند خط پیکربندی وصل می‌شود. پلن رایگان کارت بانکی نمی‌خواهد.

شروع رایگان