<# 서버스캔 설치형 점검 (윈도우 · IIS) — https://serverscan.co.kr/cli * 읽기 전용입니다. 파일 · 설정 · 레지스트리를 고치거나 지우지 않고, 아무것도 설치하지 않습니다. * 네트워크로 아무것도 보내지 않습니다. 결과는 JSON 파일 하나로만 남습니다. * 결과에는 파일 "내용"이 들어가지 않습니다. 경로 · 건수 · 설정 이름 · 소프트웨어 버전만 담깁니다. * 결과 파일을 직접 열어 확인한 뒤 https://serverscan.co.kr/cli 에 올리면 리포트로 볼 수 있습니다. 사용법(관리자 권한 PowerShell): Unblock-File .\serverscan.ps1 powershell -ExecutionPolicy Bypass -File .\serverscan.ps1 [-WebRoot C:\inetpub\wwwroot] [-Out result.json] 서명되지 않은 스크립트라 실행 정책 · 백신이 경고할 수 있습니다. 받은 파일의 SHA-256 을 사이트에 적힌 값과 비교해 주세요. #> param([string]$WebRoot = '', [string]$Out = '', [string]$AccessLog = '') $ErrorActionPreference = 'SilentlyContinue' $VERSION = '1.0' $MaxFiles = 20000 if (-not $Out) { $Out = "serverscan-$($env:COMPUTERNAME)-$(Get-Date -Format 'yyyyMMdd-HHmmss').json" } $Started = (Get-Date).ToString('o') $Findings = New-Object System.Collections.ArrayList $Checks = New-Object System.Collections.ArrayList $Tech = New-Object System.Collections.ArrayList $script:NF = 0; $script:NH = 0 function Add-Finding($id, $sev, $title, $evidence, $rec) { [void]$Findings.Add([ordered]@{ check_id = $id; severity = $sev; title = $title; evidence = [string]$evidence; recommendation = $rec }) $script:NF++; if ($sev -eq 'high') { $script:NH++ } } function Begin-Check($label) { $script:NF = 0; $script:NH = 0; Write-Host -NoNewline " · $label … " } function End-Check($id, $name, $cat, $summary) { $st = 'pass'; if ($script:NF -gt 0) { $st = 'warn' }; if ($script:NH -gt 0) { $st = 'fail' } [void]$Checks.Add([ordered]@{ check_id = $id; name = $name; category = $cat; status = $st; summary = $summary }); Write-Host $summary } function Skip-Check($id, $name, $cat, $status, $summary) { [void]$Checks.Add([ordered]@{ check_id = $id; name = $name; category = $cat; status = $status; summary = $summary }); Write-Host $summary } function Add-Tech($name, $ver) { if ($ver) { [void]$Tech.Add([ordered]@{ name = $name; version = [string]$ver }) } } function Rel($p) { if ($WebRoot -and $p.StartsWith($WebRoot, [StringComparison]::OrdinalIgnoreCase)) { return $p.Substring($WebRoot.Length) } return $p } function List($arr, $n = 8) { $a = @($arr); $s = ($a | Select-Object -First $n | ForEach-Object { Rel $_ }) -join "`n"; if ($a.Count -gt $n) { $s += "`n외 $($a.Count - $n)개" }; return $s } function Read-Head($path, $max = 2MB) { try { $fi = Get-Item -LiteralPath $path; if ($fi.Length -gt $max) { return $null }; return [IO.File]::ReadAllText($path) } catch { return $null } } Write-Host "서버스캔 설치형 점검(윈도우) $VERSION — 읽기 전용, 네트워크 전송 없음" $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { Write-Host " (관리자 권한이 아니라 IIS 설정 · 이벤트 로그 일부를 건너뛸 수 있어요)" } # ── IIS 사이트 · 웹 루트 ── $AppHost = "$env:windir\System32\inetsrv\config\applicationHost.config" $Sites = @() if (Test-Path $AppHost) { try { [xml]$ah = Get-Content -LiteralPath $AppHost -Raw foreach ($s in $ah.configuration.'system.applicationHost'.sites.site) { foreach ($app in $s.application) { foreach ($vd in $app.virtualDirectory) { if ($vd.path -eq '/') { $pp = [Environment]::ExpandEnvironmentVariables([string]$vd.physicalPath); $Sites += [pscustomobject]@{ Name = [string]$s.name; Path = $pp; App = [string]$app.path } } } } } } catch {} } if (-not $WebRoot) { $first = $Sites | Where-Object { $_.App -eq '/' -and (Test-Path $_.Path) } | Select-Object -First 1; if ($first) { $WebRoot = $first.Path } elseif (Test-Path 'C:\inetpub\wwwroot') { $WebRoot = 'C:\inetpub\wwwroot' } } $WebRoot = ($WebRoot -replace '[\\/]+$', '') Write-Host " 웹 루트: $(if ($WebRoot) { $WebRoot } else { '(찾지 못함)' }) · IIS 사이트 $(@($Sites).Count)개" # ── 1. 설치 프로그램 · 버전(CVE 대조용 — 이름과 버전만) ── $iis = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\InetStp').VersionString; if ($iis -match '(\d+\.\d+)') { Add-Tech 'Microsoft-IIS' $Matches[1] } $php = (& php -r 'echo PHP_VERSION;' 2>$null); if ($php -match '^\d+\.\d+\.\d+') { Add-Tech 'PHP' $Matches[0] } $net = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full').Version; if ($net) { Add-Tech '.NET Framework' $net } $prog = @(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' | Where-Object { $_.DisplayName }) foreach ($p in $prog) { $n = [string]$p.DisplayName; $v = [string]$p.DisplayVersion if ($n -match '^MySQL Server') { Add-Tech 'MySQL' ($v -replace '^(\d+\.\d+\.\d+).*', '$1') } elseif ($n -match '^MariaDB') { Add-Tech 'MariaDB' ($v -replace '^(\d+\.\d+\.\d+).*', '$1') } elseif ($n -match 'Apache Tomcat') { Add-Tech 'Tomcat' ($v -replace '^(\d+\.\d+\.\d+).*', '$1') } elseif ($n -match '^OpenSSL') { Add-Tech 'OpenSSL' ($v -replace '^(\d+\.\d+\.\d+[a-z]?).*', '$1') } } # ── 2. 웹서버 설정(IIS · web.config · php.ini) ── Begin-Check '웹서버 설정' $cfgFiles = @() if (Test-Path $AppHost) { $raw = Get-Content -LiteralPath $AppHost -Raw if ($raw -match ']*enabled="true"') { Add-Finding 'cli_config' 'medium' '디렉터리 목록 보기가 켜져 있음(IIS)' "$AppHost : directoryBrowse enabled=true" 'IIS 관리자 → 디렉터리 검색 → 사용 안 함. 폴더 안 파일 이름이 그대로 보여요.' } if ($raw -match ']*errorMode="Detailed"') { Add-Finding 'cli_config' 'medium' '상세 오류 화면을 외부에 보여 줌(IIS)' "$AppHost : httpErrors errorMode=Detailed" 'errorMode 를 DetailedLocalOnly 또는 Custom 으로 바꾸세요. 경로 · 코드가 노출돼요.' } if ($raw -notmatch ']*debug="true"') { $dbg += $wc } if ($t -match ']*mode="Off"') { $ce += $wc } if ($t -match ']*enabled="true"') { $db += $wc } if ($t -match ']*errorMode="Detailed"') { $he += $wc } } if ($dbg) { Add-Finding 'cli_config' 'medium' "ASP.NET 디버그 모드가 켜진 web.config $($dbg.Count)개" (List $dbg) 'compilation debug="false" 로 바꾸세요. 성능 저하와 내부 정보 노출이 생겨요.' } if ($ce) { Add-Finding 'cli_config' 'medium' "ASP.NET 상세 오류를 그대로 보여 주는 web.config $($ce.Count)개" (List $ce) 'customErrors mode="RemoteOnly" 또는 "On" 으로 바꾸세요.' } if ($db) { Add-Finding 'cli_config' 'medium' "디렉터리 목록 보기를 켠 web.config $($db.Count)개" (List $db) 'directoryBrowse enabled="false" 로 바꾸세요.' } if ($he) { Add-Finding 'cli_config' 'medium' "상세 오류 화면을 켠 web.config $($he.Count)개" (List $he) 'httpErrors errorMode="DetailedLocalOnly" 로 바꾸세요.' } $ini = (& php -r 'echo php_ini_loaded_file();' 2>$null) if ($ini -and (Test-Path $ini)) { $t = Get-Content -LiteralPath $ini -Raw if ($t -match '(?m)^\s*display_errors\s*=\s*On') { Add-Finding 'cli_config' 'medium' 'PHP 오류가 화면에 그대로 나옴' "$ini : display_errors = On" 'display_errors = Off, log_errors = On 으로 두세요.' } if ($t -match '(?m)^\s*allow_url_include\s*=\s*On') { Add-Finding 'cli_config' 'high' 'PHP 원격 파일 포함 허용' "$ini : allow_url_include = On" 'allow_url_include = Off 로 바꾸세요.' } if ($t -match '(?m)^\s*expose_php\s*=\s*On') { Add-Finding 'cli_config' 'low' 'PHP 버전을 응답 헤더에 노출' "$ini : expose_php = On" 'expose_php = Off 로 바꾸세요.' } } End-Check 'cli_config' '설정 파일 위험 옵션' '서버 설정' "IIS · web.config $($cfgFiles.Count)개 · PHP 설정 확인 (위험 옵션 $($script:NF)건)" # ── 3. 웹셸 · 업로드 폴더 실행 ── $Exec = @(); $NRecent = 0 if ($WebRoot -and (Test-Path $WebRoot)) { $Exec = @(Get-ChildItem -LiteralPath $WebRoot -Recurse -File -Include *.asp, *.aspx, *.ashx, *.asmx, *.cer, *.asa, *.php, *.phtml, *.jsp, *.cshtml | Where-Object { $_.Length -lt 3MB } | Select-Object -First $MaxFiles) $week = (Get-Date).AddDays(-7); $NRecent = @($Exec | Where-Object { $_.LastWriteTime -gt $week }).Count Begin-Check '웹셸 패턴' $sig = 'eval\s*\(\s*Request|Execute\s*\(\s*Request|ExecuteGlobal\s*\(\s*Request|eval\s*\(\s*Request\.Item|WScript\.Shell|Scripting\.FileSystemObject.{0,80}Request|ProcessStartInfo.{0,200}Request|Process\.Start\s*\(\s*Request|eval\s*\(\s*(base64_decode|gzinflate|str_rot13|\$_(POST|GET|REQUEST|COOKIE))|assert\s*\(\s*\$_(POST|GET|REQUEST)|(system|shell_exec|passthru)\s*\(\s*\$_(GET|POST|REQUEST)|FilesMan|b374k|c99shell|r57shell|Behinder|Godzilla' $shell = @(); foreach ($f in $Exec) { $t = Read-Head $f.FullName; if ($t -and $t -match $sig) { $shell += $f.FullName } } if ($shell) { Add-Finding 'webshell_pattern' 'high' "웹셸로 의심되는 파일 $($shell.Count)개" (List $shell) "파일을 바로 지우지 말고 먼저 다른 곳에 복사해 보관한 뒤(원인 분석용) 웹에서 접근을 막으세요. 예) Move-Item '경로' 'D:\quarantine\' (실행은 직접 판단). 생성 시각 전후 IIS 로그와 관리자 비밀번호를 점검하세요." } $up = @($Exec | Where-Object { (Rel $_.FullName) -match '[\\/](upload|uploads|data|files|images|img|attach|editor)[\\/]' } | ForEach-Object { $_.FullName }) if ($up) { Add-Finding 'webshell_pattern' 'medium' "업로드 폴더 안의 실행 파일 $($up.Count)개" (List $up) '업로드 폴더의 처리기 매핑에서 스크립트 실행을 끄세요: 해당 폴더 web.config 에 를 넣으면 ASP · ASPX · PHP 가 실행되지 않아요.' } $upDirs = @(Get-ChildItem -LiteralPath $WebRoot -Recurse -Directory -Depth 4 | Where-Object { $_.Name -match '^(upload|uploads|files|attach|data)$' } | Select-Object -First 30) $openUp = @(); foreach ($d in $upDirs) { $wc = Join-Path $d.FullName 'web.config'; $t = $null; if (Test-Path $wc) { $t = Read-Head $wc 256KB }; if (-not $t -or $t -notmatch 'accessPolicy="(Read|None)"') { $openUp += $d.FullName } } if ($openUp) { Add-Finding 'webshell_pattern' 'medium' "스크립트 실행이 막히지 않은 업로드 폴더 $($openUp.Count)개" (List $openUp) '폴더마다 web.config 에 를 두세요.' } End-Check 'webshell_pattern' '웹셸 · 악성 파일' '침해 흔적' "실행 파일 $($Exec.Count)개 검사 · 최근 7일 변경 $($NRecent)개 · 업로드 폴더 $($upDirs.Count)개" } else { Skip-Check 'webshell_pattern' '웹셸 · 악성 파일' '침해 흔적' 'error' '웹 루트를 찾지 못해 건너뛰었어요(-WebRoot 로 경로를 주세요)' } # ── 4. 코드 취약점(규칙 기반) ── if ($Exec.Count) { Begin-Check '코드 취약점' $rules = @( @('("\s*(SELECT|UPDATE|DELETE|INSERT)[^"]*"\s*&\s*Request)|((SELECT|UPDATE|DELETE|INSERT)[^;\n]*\+\s*Request(\.QueryString|\.Form)?\s*[\[(])', 'high', 'SQL 에 입력값을 그대로 붙이는 ASP · ASP.NET 코드', '입력값을 쿼리 문자열에 붙이지 말고 매개변수 쿼리(ADODB.Command Parameters, SqlParameter)로 바꾸세요.'), @('(mysql_query|mysqli_query|->query)\s*\([^;]*\$_(GET|POST|REQUEST|COOKIE)', 'high', 'SQL 에 입력값을 그대로 넣는 PHP 코드', '준비된 문장(prepared statement)으로 바꾸세요.'), @('(Server\.Execute|Server\.Transfer|