← 블로그 목록

UI 너머의 자동화 — WinRM으로 원격 에이전트 제어하기

2026-08-06
PlaywrightWinRMPowerShell자동화보안SW

보안 제품 QA를 하면서 처음으로 "이건 Playwright만으론 안 되겠다"는 생각이 든 건 탐지 테스트를 작성할 때였다.

탐지 결과 페이지에서 뭔가를 검증하려면, 먼저 탐지가 일어나야 한다. 탐지가 일어나려면 원격 PC에서 특정 파일을 실행해야 한다. 그 PC는 테스트 서버에서 네트워크로만 접근 가능하다.

브라우저로 클릭해서 해결될 문제가 아니었다.

사전 준비: 원격 PC에서 WinRM 활성화

코드를 실행하기 전에 원격 PC에서 한 번만 해두어야 한다. 관리자 PowerShell로.

# WinRM 서비스 활성화
Enable-PSRemoting -Force

# 테스트 서버 IP를 신뢰 목록에 추가
Set-Item WSMan:\localhost\Client\TrustedHosts -Value "테스트서버IP" -Force

# 방화벽 허용 (포트 5985)
netsh advfirewall firewall add rule name="WinRM" dir=in action=allow protocol=TCP localport=5985

도메인에 조인된 PC라면 TrustedHosts 설정 없이 바로 연결되기도 한다. 연결이 안 된다면 이 세 가지를 순서대로 확인한다.

전체 구조

[테스트 서버 (Node.js)]
  │
  │ WinRM (TCP 5985)
  ▼
[원격 Windows PC]  ← 보안 에이전트 설치됨
  │
  │ 에이전트 동작 발생
  ▼
[보안 서버]  ← 탐지 결과 기록
  │
  ▼
[브라우저 (Playwright)]  ← 결과 확인

테스트 스크립트가 하는 일은 세 가지다: 원격 PC에서 동작 실행 → 탐지 대기 → 브라우저에서 결과 확인.

executeOnAgent: 원격 PowerShell 실행

import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

async function executeOnAgent(
  command: string
): Promise<{ stdout: string; stderr: string }> {
  const psScript = `
    $cred = New-Object System.Management.Automation.PSCredential(
      '${AGENT_CONFIG.username}',
      (ConvertTo-SecureString '${AGENT_CONFIG.password}' -AsPlainText -Force)
    )
    Invoke-Command -ComputerName '${AGENT_CONFIG.ip}' -Credential $cred -ScriptBlock { ${command} }
  `;

  const encodedCommand = Buffer.from(psScript, 'utf16le').toString('base64');

  return execAsync(`powershell -EncodedCommand ${encodedCommand}`, {
    timeout: 60000,
    windowsHide: true,
  });
}

스크립트를 UTF-16LE + Base64로 인코딩하는 건 PowerShell 인라인 명령에서 따옴표 이스케이프 문제가 생겨서다. 처음엔 인라인으로 넘기다가 특수문자 때문에 계속 실패하고 나서 이 방식으로 바꿨다.

// 파일 존재 확인
const result = await executeOnAgent(`Test-Path "C:\\samples\\test.zip"`);
if (result.stdout.includes('True')) {
  console.log('파일 있음');
}

시스템 세션 vs 사용자 세션

Invoke-Command로 원격 명령을 실행하면 시스템 세션에서 실행된다. 대부분의 명령은 이것으로 충분한데, 에이전트 정책 업데이트만큼은 예외였다.

에이전트 프로그램의 정책 업데이트 실행 파일이 사용자 세션에서만 동작하도록 설계되어 있었다. 시스템 세션에서 실행하면 프로세스는 뜨는데 아무 일도 일어나지 않는다. 로그도 없다. 한동안 "왜 정책이 안 바뀌지?"를 반복하다가 이유를 알아냈다.

해결책은 schtasks/it 옵션이다.

async function updateAgentPolicyViaSchedule(): Promise<void> {
  const batchFilePath = 'C:\\run.bat';
  const agentExePath = 'C:\\Program Files (x86)\\SecurityAgent\\agent.exe';
  const taskName = 'RunInUserSession';

  const combinedScript = `
    if (-not (Test-Path "${batchFilePath}")) {
      Set-Content -Path "${batchFilePath}" -Value '"${agentExePath}"' -Encoding ASCII
    }

    schtasks /delete /tn "${taskName}" /f 2>$null

    $startTime = (Get-Date).AddMinutes(1).ToString('HH:mm')
    schtasks /create /tn "${taskName}" /tr "${batchFilePath}" /sc ONCE /st $startTime /ru "${AGENT_CONFIG.username}" /rl HIGHEST /it /f

    Start-Sleep -Seconds 2
    schtasks /run /tn "${taskName}"
  `;

  await executeOnAgent(combinedScript);
  await new Promise(resolve => setTimeout(resolve, 15000));
}

/it 옵션(Interactive Task)이 핵심이다. 로그인된 사용자 세션에서 실행되도록 강제한다.

schtasks /create의 핵심 옵션:

옵션 역할
/it 로그인된 사용자 세션에서 실행
/rl HIGHEST 최고 권한으로 실행
/sc ONCE /st [현재+1분] 한 번만, 즉시 실행 가능하도록
/f 기존 작업이 있으면 덮어쓰기

wsmprovhost를 조심해야 하는 이유

Invoke-Command를 한 번 호출할 때마다 원격 PC에서 wsmprovhost.exe 프로세스가 생긴다. 보안 제품은 이 프로세스를 탐지 대상으로 볼 수 있다.

처음엔 이렇게 짰다.

// ❌ 5번 호출 = wsmprovhost 5번 생성
await executeOnAgent('command1');
await executeOnAgent('command2');
await executeOnAgent('command3');

탐지 테스트가 자기 자신을 탐지하는 상황이 생겼다. 해결은 간단하다.

// ✅ 1번 호출로 묶어서
const combinedScript = `
  command1
  command2
  command3
`;
await executeOnAgent(combinedScript);

ZIP 샘플 압축 해제 및 실행

악성 샘플은 ZIP으로 압축해서 관리한다.

async function extractAndExecuteSample(): Promise<void> {
  const zipCheck = await executeOnAgent(`Test-Path "${AGENT_CONFIG.zipPath}"`);
  if (!zipCheck.stdout.includes('True')) {
    throw new Error(`ZIP 파일 없음: ${AGENT_CONFIG.zipPath}`);
  }

  await executeOnAgent(
    `New-Item -ItemType Directory -Force -Path "${AGENT_CONFIG.extractPath}" | Out-Null`
  );
  await executeOnAgent(
    `Expand-Archive -Path "${AGENT_CONFIG.zipPath}" -DestinationPath "${AGENT_CONFIG.extractPath}" -Force`
  );

  try {
    await executeOnAgent(
      `Set-Location "${AGENT_CONFIG.extractPath}"; & "${AGENT_CONFIG.samplePath}"`
    );
  } catch {
    // 악성 샘플 실행 시 에러는 정상일 수 있음
  }
}

실행 후에는 탐지 결과가 서버에 기록될 때까지 대기가 필요하다.

test('탐지 결과 확인', async ({ page }) => {
  await extractAndExecuteSample();
  await page.waitForTimeout(5000);

  await page.goto('https://' + globalThis.domain + '/security/incidents/alerts');
  await expect(
    page.getByRole('textbox', { name: '탐지 기간' })
  ).toBeVisible({ timeout: 5000 });
});

다음 편에서는 이렇게 만들어진 탐지 결과를 브라우저에서 검증하는 과정으로 넘어간다. Playwright Locator가 커스텀 UI에서 왜 예상대로 안 되는지, 어떻게 대응하는지.