/ logins
Szczegółowy przegląd logowań, udanych i nieudanych, z dziennika zdarzeń Windows.
Przy każdej próbie mówi, co ją wywołało: człowiek przy klawiaturze, człowiek w okienku
programu, usługa albo zadanie ze starym hasłem, czy urządzenie z sieci - i podsumowuje,
czy po serii nieudanych ktoś w końcu wszedł. Zawęża wynik do konkretnego konta, źródła,
typu logowania i okna czasu. Tylko odczyt - niczego nie wysyła.
Zakres i filtry przyjmuje w postaci, którą wypisuje skrypt fla.
Uruchomienie w PowerShell
irm https://dev.bitback.pl/logins | iex
Kopiuj
45.8 kB · 1012 linii · zaktualizowano 2026-07-30
●
Poniżej pełne, niezmienione źródło. Przeczytaj przed uruchomieniem.
# === # Szczegółowy przegląd logowań, udanych i nieudanych, z dziennika zdarzeń Windows. # Przy każdej próbie mówi, co ją wywołało: człowiek przy klawiaturze, człowiek w okienku # programu, usługa albo zadanie ze starym hasłem, czy urządzenie z sieci - i podsumowuje, # czy po serii nieudanych ktoś w końcu wszedł. Zawęża wynik do konkretnego konta, źródła, # typu logowania i okna czasu. Tylko odczyt - niczego nie wysyła. # Zakres i filtry przyjmuje w postaci, którą wypisuje skrypt fla. # === # ListLogins # Usage: irm https://dev.bitback.pl/logins | iex # # Okno czasu (ten sam zapis co w fla, domyślnie 168h): # $hback = 24; irm https://dev.bitback.pl/logins | iex # $hwin = '2026-07-28T13:40:00..2026-07-28T14:10:00'; irm https://dev.bitback.pl/logins | iex # # Zawężanie: # $user = 'DOMENA\User' konto, w pełnej postaci albo sam login # $source = '127.0.0.1' adres albo nazwa stacji # $ltype = 2 typ logowania, liczba albo skrót (Kbd, RDP) # $status = '0xC000006A' kod niepowodzenia, dotyczy tylko zdarzeń nieudanych # $failedonly = $true pomija logowania udane # # Widok: # $detail = $true dokłada proces logowania, proces wywołujący i konto inicjujące # $count = 20 ile wpisów wypisać (domyślnie 10) # $group = $true skleja powtórzenia; $interval steruje progiem w minutach # # Przykład celowania w serię nieudanych prób z jednego konta: # $hwin='2026-07-28T13:46:00..2026-07-28T13:50:00'; $user='DOMENA\User'; $detail=$true; irm https://dev.bitback.pl/logins | iex # ============================================================ # DEFAULTS - override by setting variables before iex # ============================================================ if (-not (Test-Path variable:count)) { $count = 10 } if (-not (Test-Path variable:group)) { $group = $false } if (-not (Test-Path variable:interval)) { $interval = 3 } # minutes - grouping threshold if (-not (Test-Path variable:failedonly)) { $failedonly = $false } # Filtry celowania - wypelniane komenda podpowiadana przez fla. if (-not (Test-Path variable:user)) { $user = $null } # konto: DOMENA\User albo sam User if (-not (Test-Path variable:source)) { $source = $null } # IP albo nazwa stacji, dokladnie if (-not (Test-Path variable:ltype)) { $ltype = $null } # typ logowania: 2, 10 albo 'RDP' if (-not (Test-Path variable:status)) { $status = $null } # SubStatus, np. 0xC000006A if (-not (Test-Path variable:detail)) { $detail = $false } # pola rozstrzygajace per wpis # Zmienne sa inicjalizowane jawnie, bo skrypt biegnie w sesji technika: pod # 'Set-StrictMode -Version 2.0' samo odwolanie do niezdefiniowanej rzuca wyjatkiem. if (-not (Test-Path variable:hwin)) { $hwin = $null } if (-not (Test-Path variable:hback)) { $hback = $null } # ============================================================ # OKNO CZASOWE # ============================================================ # Nazwy zmiennych, format czasu i domkniecie konca sekundy sa tu takie same jak w fla, # dzieki czemu zakres wypisany przez fla dziala po wklejeniu tutaj bez przerabiania. # Domyslne okno to 168h zamiast 24h jak w fla, bo logins sluzy do przegladania historii, # a fla do biezacego triage. $TimeFmt = 'yyyy-MM-ddTHH:mm:ss' # Wydzielone do funkcji, zeby dalo sie to sprawdzic testem bez uruchamiania calego # skryptu - w szczegolnosci sprawdzic, ze zakres wypisany przez fla jest tu przyjmowany. function Resolve-TimeWindow { param([object]$Win, [object]$Back, [datetime]$Now, [int]$DefaultHours = 168) $TimeFmt = 'yyyy-MM-ddTHH:mm:ss' if ($null -ne $Win -and "$Win".Trim() -ne '') { $rawWin = "$Win".Trim().Trim("'", '"') $winParts = $rawWin -split '\.\.' $ci = [Globalization.CultureInfo]::InvariantCulture $winFrom = [datetime]::MinValue $winTo = [datetime]::MinValue if ($winParts.Count -eq 2 -and [datetime]::TryParseExact($winParts[0].Trim(), $TimeFmt, $ci, [Globalization.DateTimeStyles]::None, [ref]$winFrom) -and [datetime]::TryParseExact($winParts[1].Trim(), $TimeFmt, $ci, [Globalization.DateTimeStyles]::None, [ref]$winTo)) { if ($winTo -lt $winFrom) { $swap = $winFrom; $winFrom = $winTo; $winTo = $swap } return [pscustomobject]@{ Start = $winFrom # Koniec domkniety do pelnej sekundy: wypisywany zakres jest ucinany do # sekund, wiec zdarzenie o .900 zostaje w oknie, z ktorego je zasugerowano. End = $winTo.AddSeconds(1).AddTicks(-1) Label = "$($winFrom.ToString($TimeFmt))..$($winTo.ToString($TimeFmt))" Explicit = $true Warning = '' } } $ostrzezenie = "Pomijam `$hwin = '$Win' - oczekiwany zapis 'yyyy-MM-ddTHH:mm:ss..yyyy-MM-ddTHH:mm:ss'." } else { $ostrzezenie = '' } $godziny = $DefaultHours if ($null -ne $Back -and "$Back".Trim() -ne '') { $parsedH = 0 if ([int]::TryParse("$Back".Trim(), [ref]$parsedH) -and $parsedH -ge 1 -and $parsedH -le 8760) { $godziny = $parsedH } else { $ostrzezenie = "Pomijam `$hback = '$Back' - oczekiwane pelne godziny z zakresu 1-8760. Wracam do ${DefaultHours}h." } } return [pscustomobject]@{ Start = $Now.AddHours(-$godziny) End = $Now Label = "ostatnie ${godziny}h" Explicit = $false Warning = $ostrzezenie } } $Now = Get-Date $Window = Resolve-TimeWindow -Win $hwin -Back $hback -Now $Now -DefaultHours 168 if ($Window.Warning) { Write-Host '' Write-Host " $($Window.Warning)" -ForegroundColor Yellow } $StartTime = $Window.Start $EndTime = $Window.End $WindowText = $Window.Label # ============================================================ # ADMIN CHECK # ============================================================ $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host '' Write-Host ' !! ERROR: Administrator privileges required !!' -ForegroundColor Red Write-Host ' Run PowerShell as Administrator.' -ForegroundColor Yellow Write-Host '' return } # ============================================================ # HELPERS # ============================================================ $ScriptVersion = '2.1' # Pelne nazwy typow logowania. Skrot w rodzaju "Kbd" mowi, ze proba przyszla z klawiatury, # a typ 2 powstaje takze wtedy, gdy program wywola LogonUser - i wlasnie ta rozbieznosc # najczesciej wprowadza w blad przy czytaniu wyniku. $LogonTypeName = @{ 2 = 'interaktywne' 3 = 'sieciowe' 4 = 'zadanie-harmonogramu' 5 = 'usluga' 7 = 'odblokowanie' 8 = 'sieciowe-haslo-jawnym-tekstem' 9 = 'nowe-poswiadczenia' 10 = 'pulpit-zdalny' 11 = 'z-cache' } function Get-LogonTypeName { param([int]$lt) if ($LogonTypeName.ContainsKey($lt)) { return $LogonTypeName[$lt] } return 'nieznany' } function Format-Hex { param([object]$Value) if ($null -eq $Value) { return '0x00000000' } if ($Value -is [string]) { if ($Value -match '^0x') { return $Value.ToUpper().Replace('0X','0x') } return '0x00000000' } # Dziennik oddaje SubStatus jako Int32 z ustawionym bitem znaku: 0xC000006A przychodzi # jako -1073741718 (zmierzone na zdarzeniu 4625, PowerShell 5.1). Maska na Int64 # obsluguje te postac oraz wariant bez znaku, bo LogonType z tego samego zdarzenia # przychodzi juz jako UInt32 - typy pol nie sa jednolite i lepiej nie zakladac ktorego. try { $u = [uint32]([int64]$Value -band 4294967295L) return '0x{0:X8}' -f $u } catch { return '0x00000000' } } $SubStatusMap = @{ '0xC0000064' = 'Account does not exist' '0xC000006A' = 'Wrong password' '0xC0000234' = 'Account locked out' '0xC0000072' = 'Account disabled' '0xC000006D' = 'Generic failure' '0xC0000071' = 'Password expired' '0xC000006F' = 'Outside allowed hours' '0xC0000070' = 'Unauthorized workstation' '0xC0000193' = 'Account expired' '0xC0000224' = 'Password must change' } # Skroty identyczne jak w fla; typ 2 nie dowodzi klawiatury, stad 'Lokal'. $LogonTypeShort = @{ 2 = 'Lokal' 3 = 'SMB' 4 = 'Batch' 5 = 'Svc' 7 = 'Unlock' 8 = 'NetClr' 9 = 'RunAs' 10 = 'RDP' 11 = 'Cache' } function Test-PrivateIP { param([string]$IP) if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-') { return $true } try { $parsed = [System.Net.IPAddress]::Parse($IP) if ($parsed.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { if ($parsed.IsIPv6LinkLocal -or $parsed.IsIPv6SiteLocal) { return $true } if ([System.Net.IPAddress]::IsLoopback($parsed)) { return $true } $firstByte = $parsed.GetAddressBytes()[0] return ($firstByte -eq 0xFC -or $firstByte -eq 0xFD) } $b = $parsed.GetAddressBytes() if ($b[0] -eq 10) { return $true } if ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) { return $true } if ($b[0] -eq 192 -and $b[1] -eq 168) { return $true } if ($b[0] -eq 127) { return $true } if ($b[0] -eq 169 -and $b[1] -eq 254) { return $true } return $false } catch { # Nazwa stacji nie parsuje sie na IP; nie zglaszamy jej jako zrodla obcego. return $true } } # ============================================================ # OUTPUT HELPERS (header / legend / no-events / footer) # ============================================================ # Naglowek opisuje wlasny przebieg: wersje, host, faktyczne okno i aktywne filtry. # Dwa powody. Po pierwsze skrypt biegnie w sesji technika, wiec zmienna zostawiona # przy poprzednim uruchomieniu dziala dalej i po cichu zawezalaby wynik - tutaj widac # ja od razu. Po drugie wyjscie bywa wklejane komus innemu albo asystentowi AI do # analizy, a wtedy sam spis zdarzen bez kontekstu nie mowi, czego dotyczy. function Show-Header { $border = '=' * 70 $what = if ($failedonly) { 'tylko nieudane (4625)' } else { 'udane i nieudane (4624+4625)' } # $filtry opisuje przebieg, $filtrySkad zbiera tylko to, co UKRYWA zdarzenia. $filtry = @() $filtrySkad = @() if ($user) { $filtry += "konto=$user"; $filtrySkad += 'konto' } if ($source) { $filtry += "zrodlo=$source"; $filtrySkad += 'zrodlo' } if ($null -ne $ltypeWanted) { $filtry += "typ=$ltypeWanted/$(Get-LogonTypeName $ltypeWanted)"; $filtrySkad += 'typ logowania' } if ($status) { $filtry += "status=$(Format-Hex $status)"; $filtrySkad += 'kod bledu' } if ($failedonly) { $filtrySkad += 'pominiete udane logowania' } if ($detail) { $filtry += 'szczegoly=tak' } Write-Host '' Write-Host $border -ForegroundColor Cyan Write-Host " ListLogins v$ScriptVersion | host=$env:COMPUTERNAME | okno=$WindowText" -ForegroundColor Cyan Write-Host (" zakres={0}..{1}" -f $StartTime.ToString($TimeFmt), $EndTime.ToString($TimeFmt)) -ForegroundColor DarkGray Write-Host " zawartosc: $what" -ForegroundColor DarkGray if ($filtry.Count -gt 0) { Write-Host " filtry: $($filtry -join ' ')" -ForegroundColor Yellow } else { Write-Host ' filtry: brak (caly zakres)' -ForegroundColor DarkGray } Write-Host $border -ForegroundColor Cyan # Filtry przychodza ze zmiennych w konsoli, wiec moga tam zostac po poprzedniej komendzie. if ($filtrySkad.Count -gt 0) { Write-Host '' Write-Host " UWAGA: wynik jest ZAWEZONY ($($filtrySkad -join ', ')). Widzisz tylko czesc zdarzen." -ForegroundColor Yellow Write-Host ' Zdjecie wszystkich filtrow (skopiuj cala linie):' -ForegroundColor DarkGray Write-Host ' $user=$null; $source=$null; $ltype=$null; $status=$null; $failedonly=$false; $hwin=$null; $hback=168; irm https://dev.bitback.pl/logins | iex' -ForegroundColor Gray } } function Show-Legend { Write-Host ' Lista od najnowszych. Kolor: zielony/blekitny = udane, zolty/czerwony = nieudane.' -ForegroundColor DarkGray Write-Host ' Kody sa podawane razem ze znaczeniem, np. typ=2/interaktywne, status=0xC000006A/zle-haslo.' -ForegroundColor DarkGray } # GUID Logon subcategory - jezykowo niezalezny od nazwy $LogonSubcategoryGuid = '{0CCE9215-69AE-11D9-BED3-505054503030}' function Test-AuditLogonEnabled { # Sprawdza FAKTYCZNE ustawienie polityki przez auditpol /r (CSV). # Format: Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting # Inclusion Setting wartosci - EN: "No Auditing"/"Success"/"Failure"/"Success and Failure" # PL: "Brak inspekcji"/"Powodzenie"/"Niepowodzenie"/"Powodzenie i Niepowodzenie" try { $csv = auditpol /get /subcategory:$LogonSubcategoryGuid /r 2>&1 | ConvertFrom-Csv -ErrorAction Stop if ($csv -is [array]) { $csv = $csv[0] } $setting = ($csv.'Inclusion Setting' -as [string]).Trim() if ($setting -match '(?i)no auditing|brak inspekcji|bez inspekcji') { return $false } if ($setting -match '(?i)success|failure|powodzenie|niepowodzenie') { return $true } return $true # nieznana wartosc - zalozyc enabled (nie blokowac) } catch { return $true # nie da sie sprawdzic - zalozyc enabled } } function Enable-AuditLogon { # Wlacz Audit Logon (glowny cel) $output1 = auditpol /set /subcategory:$LogonSubcategoryGuid /success:enable /failure:enable 2>&1 $exit1 = $LASTEXITCODE # Tez wlacz Audit Policy Change zeby na PRZYSZLOSC 4719 byly zapisywane. # Bez tego skrypt nie wie kiedy audit zostal wlaczony (caly powod tego problemu teraz). $policyChangeGuid = '{0CCE922F-69AE-11D9-BED3-505054503030}' $output2 = auditpol /set /subcategory:$policyChangeGuid /success:enable 2>&1 $verified = Test-AuditLogonEnabled return [PSCustomObject]@{ Success = ($exit1 -eq 0 -and $verified) Verified = $verified Output = (($output1 + $output2) -join "`n").Trim() } } function Get-RecentAuditLogonActivation { # Szuka Event 4719 (System audit policy was changed) dla Logon subcategory. # Zwraca DateTime ostatniej zmiany audit Logon (gdy zostalo wlaczone/zmienione) lub $null. try { $changes = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4719; StartTime=(Get-Date).AddDays(-7)} -MaxEvents 50 -ErrorAction Stop foreach ($evt in $changes) { if ($evt.Message -match [Regex]::Escape($LogonSubcategoryGuid)) { return $evt.TimeCreated } } } catch { } return $null } function Format-TimeAgo { param([datetime]$When) $delta = (Get-Date) - $When if ($delta.TotalMinutes -lt 1) { return 'less than a minute ago' } if ($delta.TotalMinutes -lt 60) { return "$([Math]::Round($delta.TotalMinutes)) minutes ago" } if ($delta.TotalHours -lt 24) { return "$([Math]::Round($delta.TotalHours, 1))h ago" } return "$([Math]::Round($delta.TotalDays, 1)) days ago" } function Show-AuditDisabledOffer { Write-Host '' Write-Host ' PROBLEM: Audit Logon is DISABLED on this computer.' -ForegroundColor Yellow Write-Host ' Windows is not recording interactive logins to Security log -' -ForegroundColor Yellow Write-Host ' this script has nothing to show until auditing is enabled.' -ForegroundColor Yellow Write-Host '' Write-Host ' Enabling takes one command (admin required, you already have it):' -ForegroundColor DarkGray Write-Host " auditpol /set /subcategory:$LogonSubcategoryGuid /success:enable /failure:enable" -ForegroundColor DarkGray Write-Host '' $answer = Read-Host ' Enable now? [Y/N]' if ($answer -match '^[Yy]') { Write-Host '' Write-Host ' Enabling Audit Logon...' -ForegroundColor Cyan $r = Enable-AuditLogon if ($r.Success) { Write-Host ' OK. Audit Logon enabled and verified.' -ForegroundColor Green Write-Host ' Your future logins (from next unlock/login) will be recorded.' -ForegroundColor Green Write-Host ' Run the script again after the next login to see the list.' -ForegroundColor Green } elseif (-not $r.Verified) { Write-Host ' Command executed, but audit is STILL disabled after verification.' -ForegroundColor Red Write-Host ' Likely GPO override (Group Policy overrides local audit policy).' -ForegroundColor Red Write-Host ' Check: gpedit.msc -> Computer Config -> Windows Settings -> Security Settings' -ForegroundColor Red Write-Host ' -> Advanced Audit Policy -> Logon/Logoff -> Audit Logon' -ForegroundColor Red Write-Host ' Or domain admin must change the domain policy.' -ForegroundColor Red } else { Write-Host ' Failed to enable:' -ForegroundColor Red Write-Host " $($r.Output)" -ForegroundColor Red Write-Host ' Run the command above manually.' -ForegroundColor Red } } else { Write-Host ' OK. You can enable later manually with the command above.' -ForegroundColor DarkGray } } function Show-NoEvents { param( [int]$RawCount = 0, [int]$SkippedCount = 0, [array]$RawEvents = @() ) Write-Host '' $auditOn = Test-AuditLogonEnabled if (-not $auditOn) { if ($RawCount -eq 0) { Write-Host " Dziennik Security nie ma zdarzen 4624/4625 w oknie $WindowText." -ForegroundColor Yellow } else { Write-Host " Of $RawCount events in log, all are noise (services LT=5, SYSTEM, machine accounts)." -ForegroundColor Yellow } Show-AuditDisabledOffer } else { # Audit on, but no data. Two signals whether audit was recently enabled: # 1. Event 4719 (System audit policy was changed) - precise but requires Audit Policy Change ON # 2. Heuristic: NO user 4624 in 7 days despite audit ON - likely freshly enabled $recentActivation = Get-RecentAuditLogonActivation $userLogonTypes = @(2, 3, 7, 10, 11) $hasUserLogonsInRaw = $false foreach ($evt in $RawEvents) { if ($evt.Id -eq 4624) { $lt = [int]$evt.Properties[8].Value if ($lt -in $userLogonTypes) { $hasUserLogonsInRaw = $true; break } } } if ($recentActivation) { $ago = Format-TimeAgo $recentActivation Write-Host " Audit Logon was enabled/changed $ago." -ForegroundColor Cyan Write-Host " ($($recentActivation.ToString('yyyy-MM-dd HH:mm:ss')))" -ForegroundColor DarkGray Write-Host ' Windows records logins ONLY from that moment - earlier logins are not visible.' -ForegroundColor Cyan Write-Host '' Write-Host ' To see your login:' -ForegroundColor Green Write-Host ' 1. Win+L (lock) and unlock with password, or log out and log in again' -ForegroundColor Green Write-Host ' 2. Run the script again - you will see the new entry' -ForegroundColor Green } elseif (-not $hasUserLogonsInRaw -and $RawCount -gt 0) { # Audit ON, service events exist but ZERO user logins in 7 days - audit likely freshly enabled Write-Host " Audyt logowan dziala, ale w oknie $WindowText nie ma zadnych logowan uzytkownikow." -ForegroundColor Cyan Write-Host ' Audit was likely enabled recently (exact moment unrecorded because Audit Policy' -ForegroundColor Cyan Write-Host ' Change was also disabled - 4719 not written).' -ForegroundColor DarkGray Write-Host '' Write-Host ' To see your login:' -ForegroundColor Green Write-Host ' 1. Win+L (lock) and unlock with password, or log out and log in again' -ForegroundColor Green Write-Host ' 2. Run the script again - you will see the new entry' -ForegroundColor Green } elseif ($RawCount -eq 0) { Write-Host " Audyt logowan dziala, ale w oknie $WindowText nie ma logowan." -ForegroundColor Green Write-Host ' (Computer unused / log recently cleared.)' -ForegroundColor DarkGray } else { Write-Host " Audit Logon is working. Of $RawCount events, $SkippedCount filtered as noise (services/SYSTEM)." -ForegroundColor Green Write-Host ' No user logins to show in this time window.' -ForegroundColor DarkGray } Write-Host ' Tip: $failedonly=$true shows only failed (Event 4625, no filtering).' -ForegroundColor DarkGray } } function Show-Tips { Write-Host ' Tips - copy whole line and paste in PowerShell:' -ForegroundColor DarkGray Write-Host ' # show more entries (default 10):' -ForegroundColor DarkGray Write-Host ' $count=20; irm https://dev.bitback.pl/logins | iex' -ForegroundColor Gray Write-Host ' # group repeated attempts:' -ForegroundColor DarkGray Write-Host ' $group=$true; irm https://dev.bitback.pl/logins | iex' -ForegroundColor Gray Write-Host ' # only failed (Event 4625):' -ForegroundColor DarkGray Write-Host ' $failedonly=$true; irm https://dev.bitback.pl/logins | iex' -ForegroundColor Gray } function Show-Footer { $border = '=' * 70 Write-Host '' Write-Host $border -ForegroundColor Cyan Write-Host " ListLogins v$ScriptVersion" -ForegroundColor Cyan Write-Host $border -ForegroundColor Cyan Write-Host '' } # ============================================================ # COLLECT EVENTS # ============================================================ # Filtr typu logowania przyjmuje i liczbe (2), i skrot (Kbd, RDP), bo fla pokazuje skrot, # a technik czesto zna numer. Normalizacja stoi przed naglowkiem, ktory ten filtr wypisuje. $ltypeWanted = $null if ($null -ne $ltype -and "$ltype".Trim() -ne '') { $rawLt = "$ltype".Trim() $parsedLt = 0 if ([int]::TryParse($rawLt, [ref]$parsedLt)) { $ltypeWanted = $parsedLt } else { foreach ($k in $LogonTypeShort.Keys) { if ($LogonTypeShort[$k] -eq $rawLt) { $ltypeWanted = [int]$k; break } } if ($null -eq $ltypeWanted) { Write-Host '' Write-Host " Pomijam `$ltype = '$ltype' - oczekiwana liczba albo jeden ze skrotow: $(($LogonTypeShort.Values | Sort-Object) -join ', ')" -ForegroundColor Yellow } } } Show-Header Write-Host '' $modeLabel = if ($failedonly) { 'failed logins (4625)' } else { 'logins (4624+4625)' } Write-Host " Reading $modeLabel..." -ForegroundColor Cyan $startTimeUtc = $StartTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") $endTimeUtc = $EndTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") # Build XPath - 4624+4625 or just 4625 if ($failedonly) { $eventFilter = "EventID=4625" } else { $eventFilter = "(EventID=4624 or EventID=4625)" } # Zakres domkniety z OBU stron. Wczesniej byl tylko poczatek, bo okno zawsze konczylo # sie "teraz"; przy $hwin z fla koniec jest istotny i bez niego wynik wychodzilby poza # zakres, ktory technik wlasnie zawezil. $filterXml = @" <QueryList> <Query Id="0" Path="Security"> <Select Path="Security"> *[System[$eventFilter and TimeCreated[@SystemTime>='$startTimeUtc' and @SystemTime<='$endTimeUtc']]] </Select> </Query> </QueryList> "@ try { $rawEvents = @(Get-WinEvent -FilterXml $filterXml -ErrorAction Stop) } catch { if ($_.Exception.Message -match 'No events were found') { Show-NoEvents -RawCount 0 -SkippedCount 0 -RawEvents @() Show-Footer return } Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red Show-Footer return } $rawCount = $rawEvents.Count # Odczyt pol PO NAZWIE z XML-a zdarzenia, a nie po numerze pozycji. # Powody, w kolejnosci waznosci: # 1. 4624 i 4625 trzymaja te same dane pod ROZNYMI indeksami, co wymuszalo dwie osobne # tablice i dwie sciezki kodu, ktore mogly sie rozjechac przy kazdej edycji. # 2. Nazwy pol sa takie same niezaleznie od jezyka Windows (inaczej niz tresc komunikatu). # 3. Pola diagnostyczne (proces wywolujacy, proces logowania) dostajemy bez dokladania # kolejnych numerkow do zapamietania. # Koszt zmierzony: okolo 0,1 ms na zdarzenie zamiast 0,035 ms, czyli 0,5 s na 5000 zdarzen. # Wobec kosztu samego czytania dziennika - szum. function ConvertFrom-EventXml { param([string]$Xml) $d = @{} try { $x = [xml]$Xml } catch { return $d } if (-not $x.Event -or -not $x.Event.EventData) { return $d } foreach ($n in $x.Event.EventData.Data) { # InnerText, nie '#text': pole puste w dzienniku to <Data Name='X'/> bez wezla # tekstowego, na ktorym odwolanie do '#text' zwraca null. if ($n.Name) { $d[$n.Name] = [string]$n.InnerText } } return $d } $events = @() $filteredOut = 0 $noiseSkipped = 0 foreach ($evt in $rawEvents) { $eventId = $evt.Id $d = ConvertFrom-EventXml -Xml $evt.ToXml() $targetUser = if ($d.ContainsKey('TargetUserName')) { $d['TargetUserName'] } else { '(?)' } $targetDomain = if ($d.ContainsKey('TargetDomainName')) { $d['TargetDomainName'] } else { '' } $logonType = 0 if ($d.ContainsKey('LogonType')) { [void][int]::TryParse($d['LogonType'], [ref]$logonType) } $sourceIP = if ($d.ContainsKey('IpAddress')) { $d['IpAddress'] } else { '-' } $workstation = if ($d.ContainsKey('WorkstationName')) { $d['WorkstationName'] } else { '' } if ($eventId -eq 4624) { $subStatus = $null # Szum systemowy - to samo kryterium co dotychczas. if ($targetUser -eq 'SYSTEM' -or $targetUser -eq 'NETWORK SERVICE' -or $targetUser -eq 'LOCAL SERVICE') { $noiseSkipped++; continue } if ($targetUser -match '\$$') { $noiseSkipped++; continue } # konta maszynowe if ($logonType -eq 5) { $noiseSkipped++; continue } # start uslug # Konta wirtualne pulpitu, logowane typem 2. Dopasowanie po nazwie konta, bo nazwa # domeny ('Window Manager') zalezy od jezyka systemu, a wzorzec DWM-n / UMFD-n nie. if ($targetUser -match '^(DWM|UMFD)-\d+$') { $noiseSkipped++; continue } } else { $subStatus = if ($d.ContainsKey('SubStatus')) { Format-Hex $d['SubStatus'] } else { '0x00000000' } } $srcLabel = $sourceIP if ($srcLabel -eq '-' -or [string]::IsNullOrWhiteSpace($srcLabel)) { $srcLabel = if ($workstation) { $workstation } else { '-' } } $fullUser = if ($targetDomain -and $targetDomain -ne '-') { "$targetDomain\$targetUser" } else { $targetUser } # --- filtry celowania --- # Konto dopasowujemy w obu postaciach: pelnej (DOMENA\User) i samej nazwy, bo fla # wypisuje pelna, a technik czesto pamieta tylko login. if ($user) { $wanted = "$user".Trim() # Trzeci warunek zdejmuje domene z wartosci OCZEKIWANEJ i porownuje z sama nazwa # konta ze zdarzenia. Dzieki temu 'DOMENA\jkowalski' trafia takze w zdarzenia, # ktore maja pusta domene - a tak zapisuja sie proby na konto nieistniejace # w domenie, czyli najciekawszy operacyjnie przypadek. $wantedBare = $wanted -replace '^.*\\', '' if (($fullUser -ne $wanted) -and ($targetUser -ne $wanted) -and ($targetUser -ne $wantedBare)) { $filteredOut++; continue } } if ($source) { $wantedSrc = "$source".Trim() if (($srcLabel -ne $wantedSrc) -and ($sourceIP -ne $wantedSrc) -and ($workstation -ne $wantedSrc)) { $filteredOut++; continue } } # Typ logowania, tak samo jak kod niepowodzenia, zawezamy wylacznie wsrod zdarzen # nieudanych. Udane wejscie po serii prob bywa zapisane innym typem niz same proby # (proby jako sieciowe, wejscie jako pulpit zdalny), wiec filtrowanie po typie takze # zdarzen udanych ukryloby dokladnie ten wpis, ktorego technik szuka. if ($null -ne $ltypeWanted -and $eventId -eq 4625 -and $logonType -ne $ltypeWanted) { $filteredOut++; continue } # Status filtruje WYLACZNIE nieudane. Gdyby ucinal tez 4624, ukrylby udane logowanie # atakujacego - czyli odpowiedz na pytanie, po ktore technik tu przyszedl. if ($status -and $eventId -eq 4625) { if ($subStatus -ne (Format-Hex $status)) { $filteredOut++; continue } } $events += [PSCustomObject]@{ Time = $evt.TimeCreated EventId = $eventId User = $fullUser Source = $srcLabel LogonType = $logonType SubStatus = $subStatus LogonProc = if ($d.ContainsKey('LogonProcessName')) { $d['LogonProcessName'].Trim() } else { '' } AuthPkg = if ($d.ContainsKey('AuthenticationPackageName')) { $d['AuthenticationPackageName'].Trim() } else { '' } Process = if ($d.ContainsKey('ProcessName')) { $d['ProcessName'] } else { '' } Subject = if ($d.ContainsKey('SubjectUserName')) { $d['SubjectUserName'] } else { '' } } } $events = @($events | Sort-Object Time -Descending) if ($events.Count -eq 0) { Show-NoEvents -RawCount $rawCount -SkippedCount ($rawCount - $events.Count) -RawEvents $rawEvents Show-Footer return } Write-Host " $($events.Count) events." -ForegroundColor Cyan Write-Host '' $border = '=' * 70 # ============================================================ # OUTPUT HELPERS # ============================================================ function Get-StatusTag { param($EventId, $SubStatus) if ($EventId -eq 4624) { return 'OK' } if ($SubStatus -and $SubStatusMap.ContainsKey($SubStatus)) { return "FAIL: $($SubStatusMap[$SubStatus])" } return 'FAIL' } function Get-EventColor { param($EventId, $LogonType, $SubStatus, [int]$Count = 1) if ($EventId -eq 4624) { if ($LogonType -eq 10) { return 'Cyan' } return 'Green' } if ($LogonType -eq 10) { return 'Red' } if ($SubStatus -eq '0xC000006A') { return 'Yellow' } if ($Count -ge 20) { return 'Red' } return 'DarkYellow' } function Get-LogonTypeShort { param([int]$lt) if ($LogonTypeShort.ContainsKey($lt)) { return $LogonTypeShort[$lt] } return "T$lt" } # Wynik proby razem z kodem. Sam kod nic nie mowi czytajacemu, a sam opis nie nadaje sie # do wklejenia jako filtr - stad obie postaci obok siebie. function Get-OutcomeText { param($EventId, $SubStatus) if ($EventId -eq 4624) { return 'UDANE' } if ($SubStatus -and $SubStatusMap.ContainsKey($SubStatus)) { return "NIEUDANE status=$SubStatus/$($SubStatusMap[$SubStatus])" } if ($SubStatus) { return "NIEUDANE status=$SubStatus/nieznany-kod" } return 'NIEUDANE' } # Proces logowania rozstrzyga, czy proba pochodzila od czlowieka przy konsoli, czy od # programu. To jedyne pole, ktore odroznia te dwie sytuacje - typ logowania pokazuje # w obu przypadkach to samo. function Format-LogonProc { param([string]$lp) if (-not $lp) { return '-' } switch ($lp) { 'User32' { return 'User32/klawiatura' } 'Advapi' { return 'Advapi/programowo' } 'NtLmSsp' { return 'NtLmSsp/sieciowo' } 'Kerberos' { return 'Kerberos/sieciowo' } default { return $lp } } } function Format-ProcName { param([string]$p) if (-not $p -or $p -eq '-') { return '-' } # Sama nazwa pliku wystarcza do rozpoznania sprawcy, a pelna sciezka ujawnia # inwentarz oprogramowania w wyjsciu, ktore bywa pokazywane przy kliencie. try { return (Split-Path $p -Leaf) } catch { return $p } } # Procesy, ktore wystawiaja okno na haslo - zdarzenie wyglada wtedy jak wywolanie programowe. $InteractiveProcs = @( 'consent.exe', # okno UAC "podaj dane administratora" 'runas.exe', 'explorer.exe', # "Uruchom jako inny uzytkownik", mapowanie dysku sieciowego 'mmc.exe', # konsole zarzadzania (AD, DNS, DHCP, uslugi) 'powershell.exe', 'pwsh.exe', 'cmd.exe', 'credwiz.exe', 'rundll32.exe', # okno "Menedzer poswiadczen" / keymgr 'mstsc.exe', # klient pulpitu zdalnego zapisujacy poswiadczenia 'LogonUI.exe', 'winlogon.exe' ) # Kolejnosc regul jest znaczaca: User32 rozstrzyga ponad typem logowania i procesem. function Get-CauseInfo { param( [int]$EventId, [int]$LogonType, [string]$LogonProc, [string]$ProcessLeaf, [string]$Source, [bool]$SourceExternal = $false ) $lp = ($LogonProc -as [string]).Trim() $proc = ($ProcessLeaf -as [string]).Trim() $procTxt = if ($proc -and $proc -ne '-') { $proc } else { '' } if ($lp -eq 'User32') { return [pscustomobject]@{ Key = 'klawiatura' Kto = 'CZLOWIEK przy tym komputerze' Opis = 'haslo wpisano na ekranie logowania Windows, czyli ktos siedzial przy tej maszynie (albo byl na niej zalogowany zdalnie i wywolal ekran logowania)' Co = 'ustal, kto mial dostep do konsoli w tych godzinach; to nie jest usterka techniczna' } } if ($LogonType -eq 4) { return [pscustomobject]@{ Key = 'harmonogram' Kto = 'zadanie harmonogramu' Opis = "logowanie wywolalo zadanie z Harmonogramu zadan$(if ($procTxt) { " (proces $procTxt)" })" Co = 'najczestsza przyczyna to haslo zmienione po zapisaniu zadania - popraw haslo w zadaniu (taskschd.msc)' } } if ($LogonType -eq 5) { return [pscustomobject]@{ Key = 'usluga' Kto = 'usluga systemowa' Opis = "logowanie wywolala usluga Windows$(if ($procTxt) { " (proces $procTxt)" })" Co = 'usterka techniczna, nie atak: usluga ma zapisane stare haslo - popraw je w services.msc na zakladce Logowanie' } } if ($LogonType -eq 10) { if ($SourceExternal) { return [pscustomobject]@{ Key = 'rdp-zewnetrzny' Kto = "pulpit zdalny z adresu SPOZA sieci lokalnej ($Source)" Opis = 'ktos probuje wejsc przez pulpit zdalny z internetu' Co = 'to jest powazne: zablokuj ten adres na zaporze i sprawdz, czy port pulpitu zdalnego jest wystawiony do internetu' } } return [pscustomobject]@{ Key = 'rdp-wewnetrzny' Kto = "pulpit zdalny z sieci lokalnej ($Source)" Opis = 'ktos lub cos laczy sie pulpitem zdalnym z urzadzenia w sieci' Co = "ustal, co stoi pod adresem $Source i kto z niego korzysta" } } if ($LogonType -eq 3 -or $LogonType -eq 8) { if ($SourceExternal) { return [pscustomobject]@{ Key = 'siec-zewnetrzna' Kto = "urzadzenie SPOZA sieci lokalnej ($Source)" Opis = 'proba logowania po sieci z adresu, ktory nie nalezy do sieci lokalnej' Co = 'zablokuj ten adres na zaporze i sprawdz, co jest z zewnatrz dostepne' } } if ([string]::IsNullOrWhiteSpace($Source) -or $Source -eq '-') { return [pscustomobject]@{ Key = 'siec-bez-adresu' Kto = 'zadanie po sieci, dziennik nie zapisal skad' Opis = 'proba logowania przyszla po sieci, ale zdarzenie nie ma ani adresu, ani nazwy stacji - tak wygladaja najczesciej odrzucone proby uzgodnienia sesji SMB, zwykle szum sieciowy' Co = 'jesli takich wpisow jest kilka na dobe i nie ma przy nich nazwy konta, mozesz je pominac; przy wiekszej liczbie sprawdz, co skanuje siec' } } return [pscustomobject]@{ Key = 'siec-wewnetrzna' Kto = "urzadzenie w sieci lokalnej ($Source)" Opis = 'proba logowania po sieci - typowo zamapowany dysk, drukarka, skaner, kopia zapasowa albo telefon z zapisanym starym haslem' Co = "ustal, co stoi pod $Source; jesli to sprzet lub program z zapisanym haslem - popraw tam haslo" } } if ($lp -eq 'Advapi') { if ($procTxt -and ($InteractiveProcs -contains $procTxt)) { return [pscustomobject]@{ Key = 'okienko' Kto = "CZLOWIEK, ale w okienku programu $procTxt (nie na ekranie logowania)" Opis = "haslo wpisano w oknie, ktore wystawil $procTxt - tak wyglada podanie danych administratora w okienku UAC, opcja Uruchom jako inny uzytkownik, konsola zarzadzania albo mapowanie dysku sieciowego" Co = 'zapytaj osobe pracujaca wtedy na tej maszynie, do czego podawala haslo - z jej strony to nie bylo "logowanie sie", wiec moze zaprzeczac w dobrej wierze' } } if ($procTxt) { return [pscustomobject]@{ Key = 'program' Kto = "program $procTxt" Opis = "logowanie wywolal program $procTxt przez funkcje systemowa, a nie czlowiek przez klawiature" Co = "sprawdz, czy $procTxt ma gdzies zapisane haslo tego konta (zadanie, usluga, wlasna konfiguracja) i popraw je tam" } } return [pscustomobject]@{ Key = 'program-bez-nazwy' Kto = 'jakis program na tej maszynie' Opis = 'logowanie wywolal program przez funkcje systemowa, ale dziennik nie zapisal jego nazwy' Co = 'poszukaj w tych samych minutach innych zdarzen w dzienniku aplikacji; sprawdz zadania harmonogramu i uslugi dzialajace na tym koncie' } } if ($lp -eq 'NtLmSsp' -or $lp -eq 'Kerberos') { return [pscustomobject]@{ Key = 'siec-uwierzytelnianie' Kto = 'zadanie z sieci (uwierzytelnianie domenowe)' Opis = "proba przyszla przez mechanizm sieciowy $lp - to prosba przekazana przez inny komputer albo usluge, nie klawiatura tej maszyny" Co = 'ustal, ktore urzadzenie pyta - sprawdz nazwe stacji i adres zrodla w wierszu zdarzenia' } } return [pscustomobject]@{ Key = 'nieustalone' Kto = 'nie ustalono' Opis = "dziennik nie zapisal pola rozstrzygajacego (proces logowania: '$(if ($lp) { $lp } else { 'brak' })')" Co = 'sprawdz to zdarzenie w Podgladzie zdarzen: dziennik Security, ten sam znacznik czasu' } } # Tutaj, nie w petli zbierajacej - Get-CauseInfo i Format-ProcName sa zdefiniowane wyzej, # ale petla zbierajaca stoi przed nimi. foreach ($e in $events) { $cause = Get-CauseInfo -EventId $e.EventId -LogonType $e.LogonType -LogonProc $e.LogonProc ` -ProcessLeaf (Format-ProcName $e.Process) -Source $e.Source ` -SourceExternal (-not (Test-PrivateIP $e.Source)) $e | Add-Member -NotePropertyName Cause -NotePropertyValue $cause -Force } function Show-Meaning { param([array]$All) $failed = @($All | Where-Object { $_.EventId -eq 4625 }) if ($failed.Count -eq 0) { return } $byCause = @($failed | Group-Object { $_.Cause.Key } | Sort-Object Count -Descending) $top = $byCause[0] $topCause = $top.Group[0].Cause Write-Host '' Write-Host ('=' * 70) -ForegroundColor Cyan Write-Host ' CO TO ZNACZY' -ForegroundColor Cyan Write-Host ('=' * 70) -ForegroundColor Cyan Write-Host (" Zrodlo problemu: {0}" -f $topCause.Kto) -ForegroundColor White Write-Host (" {0} z {1} nieudanych prob" -f $top.Count, $failed.Count) -ForegroundColor DarkGray Write-Host (" {0}." -f $topCause.Opis) -ForegroundColor Gray Write-Host '' Write-Host (" Co dalej: {0}." -f $topCause.Co) -ForegroundColor Yellow if ($byCause.Count -gt 1) { Write-Host '' Write-Host ' W tym samym oknie sa tez inne przyczyny:' -ForegroundColor DarkGray foreach ($c in $byCause[1..($byCause.Count - 1)]) { Write-Host (" - {0}x {1}" -f $c.Count, $c.Group[0].Cause.Kto) -ForegroundColor DarkGray } } # 4624 zapisuja sie parami o identycznym czasie, wiec grupujemy po znaczniku czasu. $ostatniaProba = ($failed | Sort-Object Time -Descending | Select-Object -First 1).Time $udanePo = @($All | Where-Object { $_.EventId -eq 4624 -and $_.Time -ge $ostatniaProba } | Sort-Object Time | Group-Object { $_.Time.ToString('o') }) Write-Host '' if ($udanePo.Count -gt 0) { $pierwsze = $udanePo[0].Group[0] $ile = [Math]::Round(($pierwsze.Time - $ostatniaProba).TotalSeconds) Write-Host (" SKUTEK: po ostatniej nieudanej probie logowanie SIE UDALO - {0}, {1} s pozniej, konto {2}." -f ` $pierwsze.Time.ToString($TimeFmt), $ile, $pierwsze.User) -ForegroundColor Yellow Write-Host (" tamto udane logowanie: {0}" -f $pierwsze.Cause.Kto) -ForegroundColor DarkGray } elseif ($failedonly) { Write-Host ' SKUTEK: nie wiadomo, czy ktos w koncu wszedl - $failedonly=$true ukrywa udane logowania.' -ForegroundColor DarkGray } else { Write-Host ' SKUTEK: w tym oknie po ostatniej nieudanej probie NIE bylo udanego logowania.' -ForegroundColor Green } } # ============================================================ # OUTPUT # ============================================================ Write-Host $border -ForegroundColor Cyan Write-Host " $env:COMPUTERNAME" -ForegroundColor White Write-Host $border -ForegroundColor Cyan Write-Host '' Show-Legend Write-Host '' if (-not $group) { # ============================================================ # SIMPLE MODE # ============================================================ $shown = @($events | Select-Object -First $count) $idx = 0 foreach ($e in $shown) { $idx++ # Czas w tym samym formacie, ktorego uzywa $hwin - kazdy znacznik widoczny na # ekranie da sie wkleic jako granice okna bez przepisywania. $timeStr = $e.Time.ToString($TimeFmt) $color = Get-EventColor $e.EventId $e.LogonType $e.SubStatus Write-Host (" {0,2}. {1} typ={2}/{3}" -f $idx, $timeStr, $e.LogonType, (Get-LogonTypeName $e.LogonType)) -ForegroundColor $color Write-Host (" konto={0} zrodlo={1} {2}" -f $e.User, $e.Source, (Get-OutcomeText $e.EventId $e.SubStatus)) -ForegroundColor DarkGray Write-Host (" -> {0}" -f $e.Cause.Kto) -ForegroundColor DarkCyan if ($detail) { Write-Host (" logon={0} proces={1} pakiet={2} inicjator={3}" -f ` (Format-LogonProc $e.LogonProc), (Format-ProcName $e.Process), $(if ($e.AuthPkg) { $e.AuthPkg } else { '-' }), $(if ($e.Subject) { $e.Subject } else { '-' })) -ForegroundColor DarkGray } } Write-Host '' Write-Host " Showing $($shown.Count) of $($events.Count)." -ForegroundColor DarkGray # $events, nie $shown - podsumowanie dotyczy calego okna, nie widocznej czesci listy. Show-Meaning $events Write-Host '' Show-Tips } else { # ============================================================ # GROUPED MODE # ============================================================ $sorted = $events | Sort-Object Time $groups = @() $currentGroup = $null foreach ($e in $sorted) { $key = "$($e.EventId)|$($e.Source)|$($e.User)|$($e.SubStatus)|$($e.LogonType)" if ($null -eq $currentGroup) { $currentGroup = @{ Key = $key EventId = $e.EventId Source = $e.Source User = $e.User LogonType = $e.LogonType SubStatus = $e.SubStatus First = $e.Time Last = $e.Time Count = 1 } continue } $gap = ($e.Time - $currentGroup.Last).TotalMinutes if ($key -eq $currentGroup.Key -and $gap -le $interval) { $currentGroup.Last = $e.Time $currentGroup.Count++ } else { $groups += [PSCustomObject]$currentGroup $currentGroup = @{ Key = $key EventId = $e.EventId Source = $e.Source User = $e.User LogonType = $e.LogonType SubStatus = $e.SubStatus First = $e.Time Last = $e.Time Count = 1 } } } if ($null -ne $currentGroup) { $groups += [PSCustomObject]$currentGroup } $groups = @($groups | Sort-Object Last -Descending) $shown = @($groups | Select-Object -First $count) Write-Host '' $idx = 0 foreach ($g in $shown) { $idx++ $ltShort = Get-LogonTypeShort $g.LogonType $tag = Get-StatusTag $g.EventId $g.SubStatus $color = Get-EventColor $g.EventId $g.LogonType $g.SubStatus $g.Count if ($g.Count -eq 1) { $timeStr = $g.First.ToString('yyyy-MM-dd HH:mm:ss') Write-Host (" {0,2}. {1} {2,-6} {3,-25} <- {4}" -f $idx, $timeStr, $ltShort, $g.User, $g.Source) -ForegroundColor $color Write-Host (" {0}" -f $tag) -ForegroundColor DarkGray } else { $fromStr = $g.First.ToString('yyyy-MM-dd HH:mm:ss') $toStr = $g.Last.ToString('yyyy-MM-dd HH:mm:ss') $duration = $g.Last - $g.First if ($duration.TotalHours -ge 1) { $durStr = "{0:0}h {1}min" -f [Math]::Floor($duration.TotalHours), $duration.Minutes } elseif ($duration.TotalMinutes -ge 1) { $durStr = "{0}min" -f [Math]::Ceiling($duration.TotalMinutes) } else { $durStr = "{0}sec" -f [Math]::Ceiling($duration.TotalSeconds) } Write-Host (" {0,2}. [{1}x] {2,-6} {3,-25} <- {4}" -f $idx, $g.Count, $ltShort, $g.User, $g.Source) -ForegroundColor $color Write-Host (" {0}" -f $tag) -ForegroundColor DarkGray Write-Host (" from {0} to {1} ({2})" -f $fromStr, $toStr, $durStr) -ForegroundColor DarkGray } } Write-Host '' $totalRaw = ($shown | Measure-Object -Property Count -Sum).Sum Write-Host " Showing $($shown.Count) groups ($totalRaw events) of $($events.Count) total. Grouping threshold: ${interval}min." -ForegroundColor DarkGray Show-Meaning $events Write-Host '' Show-Tips } Show-Footer