8 minute read

[AD 홈랩] 더미 계정 설계부터 GPO 기반 RDP 접근 제어까지 — 실무 방식으로 구축하기

환경: Hyper-V 홈랩 (도메인 azlab, DC01=WS2022 / DC02=WS2025 혼합 포리스트, Win10/Win11 클라이언트) 목표: 실무에서 쓰는 방식 그대로 — 최소 권한 계정 설계, OU 기반 구조, GPO 중앙 관리, 그룹 기반 RDP 접근 제어

0. 왜 이렇게 만들었나

AD/GPO를 학습하기 위해 홈랩 환경을 구축했다. 문서로 읽는 것과 직접 만들어보는 것은 다르기에, 단순히 계정 몇 개 만들고 끝이 아니라 실무에서 지켜야 하는 원칙들을 그대로 반영하는 것을 목표로 잡았다.

  • 도메인 조인 계정에 Domain Admins를 주지 않는다 (최소 권한 위임)
  • 머신별 수동 설정 대신 GPO 중앙 관리
  • 스크립트는 재실행 가능하고, 잘못된 머신에서 실행되면 스스로 멈춰야 한다

1. 계정 설계 — 용도별 분리

계정을 용도별로 나누고 OU 구조부터 잡았다.

OU=LabAccounts
 ├─ OU=Admins        adm-jmpark(Domain Admins), adm-dba(SQL sysadmin 대상)
 ├─ OU=Service       svc-adjoin(조인 전용), svc-sqlapp, svc-sqlread
 ├─ OU=Users         user01~05 + GG-Dev/GG-Sales 그룹
 └─ OU=Workstations  WIN10-CL01, WIN11-CL01 (클라이언트 컴퓨터)

핵심은 svc-adjoin이다. 도메인 조인용 계정에 Domain Admins를 주는 건 대표적인 안티패턴이라, Computers 컨테이너에 컴퓨터 개체 관련 권한만 dsacls로 위임했다.

$computersDN = "CN=Computers,DC=azlab,DC=istn,DC=co,DC=kr"

# 컴퓨터 개체 생성/삭제
dsacls $computersDN /G "AZLAB\svc-adjoin:CCDC;computer"

# 조인/재조인에 필요한 속성 권한 (Microsoft 문서화 정식 세트)
dsacls $computersDN /I:S /G "AZLAB\svc-adjoin:CA;Reset Password;computer" `
    "AZLAB\svc-adjoin:RPWP;Account Restrictions;computer" `
    "AZLAB\svc-adjoin:SW;Validated write to DNS host name;computer" `
    "AZLAB\svc-adjoin:SW;Validated write to service principal name;computer"

각 권한의 의미:

권한 왜 필요한가
CCDC (Create/Delete Child) 컴퓨터 개체 생성/삭제
Reset Password 재조인 시 컴퓨터 계정 암호 재설정
Account Restrictions (RPWP) 조인 과정이 userAccountControl을 쓰기 때문 — 특히 기존/사전 생성 개체 재조인 시 필수
Validated write to DNS host name / SPN dNSHostName·servicePrincipalName 갱신

여기서 디테일 하나 — 처음엔 dNSHostName/SPN에 WP(일반 쓰기)를 줬다가 SW(Validated Write)로 바꿨다. WP는 임의 값을 쓸 수 있지만, Validated Write는 “자기 컴퓨터 이름과 일치하는 값만” 쓰도록 AD가 검증한다. SPN에 임의 값을 쓸 수 있으면 Kerberos 위장 공격 여지가 생기므로, 최소 권한 원칙상 Validated Write가 정석이다.

함정: dsacls가 “The parameter is incorrect”를 뱉는다면 — GUID 방식으로

그런데 실제로는 위 dsacls 명령이 계속 실패했다. CCDC는 성공했지만 4종 권한 일괄 부여가 “The parameter is incorrect”로 거부됐고, AD에 등록된 확장 권한 displayName을 직접 조회해 정확히 맞춰도(Get-ADObject -SearchBase "CN=Extended-Rights,...") 결과는 같았다. dsacls의 권한 이름 해석은 로캘/버전에 따라 까다롭기로 유명하다.

해결은 이름 해석을 아예 우회하는 것 — 권한 GUID로 ACE를 직접 추가하는 PowerShell 방식이다. 확장 권한 GUID는 Well-known SID처럼 모든 AD에서 동일한 고정값이라 실패할 여지가 없다:

$dn = "AD:\CN=Computers,DC=azlab,DC=istn,DC=co,DC=kr"
$sid = (Get-ADUser svc-adjoin).SID
$computerClass = [Guid]"bf967a86-0de6-11d0-a285-00aa003049e2"   # computer 클래스

$rights = @(
    @{Guid=[Guid]"00299570-246d-11d0-a768-00aa006e0529"; Type="ExtendedRight"; Name="Reset Password"},
    @{Guid=[Guid]"4c164200-20c0-11d0-a768-00aa006e0529"; Type="ReadProperty, WriteProperty"; Name="Account Restrictions"},
    @{Guid=[Guid]"72e39547-7b18-11d1-adef-00c04fd8d5cd"; Type="Self"; Name="Validated write to DNS host name"},
    @{Guid=[Guid]"f3a64788-5306-11d1-a9c5-0000f80367c1"; Type="Self"; Name="Validated write to SPN"}
)

$acl = Get-Acl $dn
foreach ($r in $rights) {
    $acl.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
        $sid, $r.Type, "Allow", $r.Guid, "Descendents", $computerClass)))
}
Set-Acl $dn $acl

적용 확인:

(Get-Acl $dn).Access | Where-Object IdentityReference -like "*svc-adjoin*" |
    ft ActiveDirectoryRights, ObjectType, InheritedObjectType -AutoSize

      ActiveDirectoryRights ObjectType                           InheritedObjectType
      --------------------- ----------                           -------------------
   CreateChild, DeleteChild bf967a86-...                         00000000-...
ReadProperty, WriteProperty 4c164200-... (Account Restrictions)  bf967a86-... (computer)
                       Self f3a64788-... (Validated SPN)         bf967a86-...
                       Self 72e39547-... (Validated DNS name)    bf967a86-...
              ExtendedRight 00299570-... (Reset Password)        bf967a86-...

정식 5종 ACE가 전부 들어갔다. 교훈은 앞의 SID 이야기와 같다: 이름(displayName)은 해석에 의존하지만, GUID는 모든 AD에서 불변이다. ACL 스크립팅은 GUID가 정석.

이 위임이 “진짜” 동작하는지 확인하려면 — ms-DS-MachineAccountQuota

한 가지 함정이 있다. AD 기본값으로 모든 인증된 사용자는 위임 없이도 컴퓨터를 10대까지 조인할 수 있다 (ms-DS-MachineAccountQuota=10). 즉 위임을 만들어놓고 svc-adjoin으로 조인이 성공해도, 그게 위임 덕분인지 기본 쿼터 덕분인지 구분이 안 된다.

# 기본 쿼터를 0으로 — 이제 위임받은 계정만 조인 가능
Set-ADDomain azlab.istn.co.kr -Replace @{"ms-DS-MachineAccountQuota"=0}

# 확인
Get-ADObject (Get-ADDomain).DistinguishedName -Properties ms-DS-MachineAccountQuota | fl ms-DS-MachineAccountQuota
# ms-DS-MachineAccountQuota : 0

이후 svc-adjoin 조인 성공 / 일반 사용자 조인 실패가 재현되면 최소 권한 위임이 완성된 것이다. 덧붙이면 MAQ=10 기본값은 보안 관점에서도 유명한 공격 표면(임의 사용자의 머신 계정 생성 → RBCD 등 악용)이라, 실무 하드닝에서도 0으로 막는 것이 권장된다.

DB 계정도 권한 차등으로 3개를 만들었다: adm-dba(sysadmin), svc-sqlapp(datareader/writer), svc-sqlread(datareader만). 나중에 SQL 랩에서 “권한 거부 재현” 실습에 그대로 쓰인다.

2. 첫 번째 함정 — 스크립트 인코딩 (UTF-8 BOM)

계정 생성 스크립트를 DC01에서 실행하자마자 파서 오류가 쏟아졌다. 한글이 ì¡°ì¸처럼 깨진 것이 힌트.

원인: Windows PowerShell 5.1은 BOM 없는 UTF-8 파일을 ANSI(CP949)로 읽는다. 한글 주석/문자열이 깨지면서 따옴표·중괄호 파싱까지 무너진 것.

# 해결: UTF-8 with BOM으로 재저장
$c = Get-Content .\script.ps1 -Raw -Encoding UTF8
Set-Content .\script.ps1 -Value $c -Encoding UTF8   # PS5.1의 UTF8 = BOM 포함

“내 PC(PS7)에선 되는데 서버(PS5.1)에서 깨져요”의 원인 1순위. 한글 포함 .ps1은 무조건 UTF-8 with BOM.

3. 두 번째 함정 — 로컬 그룹 이름은 현지화된다

RDP 권한을 주려고 Add-LocalGroupMember -Group "Remote Desktop Users"를 실행했더니 GroupNotFound. 한국어 Windows에서는 그룹 이름이 “원격 데스크톱 사용자”다.

해결은 언어 무관한 well-known SID 사용:

Add-LocalGroupMember -Group (Get-LocalGroup -SID "S-1-5-32-555") -Member "AZLAB\GG-Dev"

영어/한글 서버가 섞인 고객 환경에서 스크립트가 한쪽에서만 실패하는 전형적 원인. 로컬 그룹은 항상 SID로 참조하는 것이 정석이다.

Well-known SID — 알아두면 평생 쓰는 개념

S-1-5-32-555는 임의의 값이 아니라 Microsoft가 정의한 Well-known SID다. Windows의 빌트인 보안 주체들은 전 세계 모든 시스템에서 동일한 고정 SID를 갖는다 (공식 목록: MS Learn “Well-known SIDs” 문서, 프로토콜 규격 MS-DTYP 2.4.2.4).

SID의 구조:

S-1-5-32-555
│ │ │  │   └─ RID 555 = Remote Desktop Users
│ │ │  └──── 32 = Builtin 도메인 (모든 Windows에 존재)
│ │ └─────── 5 = NT Authority
│ └───────── 식별자 기관 (Identifier Authority)
└─────────── 리비전

S-1-5-32-xxx 계열이 전부 빌트인 로컬 그룹이고, 마지막 RID로 그룹이 구분된다. 자주 만나는 값들:

SID 그룹 (영어) 한국어 표시명
S-1-5-32-544 Administrators 관리자
S-1-5-32-545 Users 사용자
S-1-5-32-546 Guests 게스트
S-1-5-32-555 Remote Desktop Users 원격 데스크톱 사용자
S-1-5-32-556 Network Configuration Operators 네트워크 구성 운영자
S-1-5-32-558 Performance Monitor Users 성능 모니터 사용자

핵심 성질: 표시 이름은 언어팩에 따라 현지화되지만, SID는 모든 Windows에서 동일하다. 직접 확인해볼 수도 있다:

(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-555")).Translate([System.Security.Principal.NTAccount])
# 한국어 시스템: BUILTIN\원격 데스크톱 사용자
# 영어 시스템:   BUILTIN\Remote Desktop Users  ← 이름은 달라도 SID는 동일

뒤에 나올 GPO의 Restricted Groups(GptTmpl.inf)에서도 *S-1-5-32-555 형태로 SID가 그대로 쓰인다 — 다국어 클라이언트에 배포돼도 동작해야 하니 Windows 내부도 이름이 아닌 SID로 기록하는 것이다.

그리고 여기서 개념 하나 — 이 명령은 DC가 아니라 대상 머신에서 실행해야 한다. DC에는 로컬 SAM이 없어서 Get-LocalGroup 자체가 동작하지 않는다. “누가 어느 머신에 RDP 가능한가”는 항상 대상 머신의 로컬 그룹이 결정하고, 이를 중앙에서 관리하는 방법이 바로 GPO다.

4. 세 번째 함정 — 클라이언트는 원격 관리가 기본 차단

DC에서 클라이언트 이름을 원격으로 바꾸려 하자:

Rename-Computer : ... The RPC server is unavailable. (0x800706BA)

진단해보니 DNS 등록은 정상인데 ping도 TimedOut. Windows 10/11 클라이언트는 인바운드 원격 관리(WMI/DCOM, WinRM)와 ICMP가 기본 차단이다. 서버 감각으로 접근하면 당하는 부분.

당장은 각 VM 콘솔에서 직접 처리하고, 근본 해결은 GPO로 WinRM/방화벽을 중앙 배포하는 것(5절)으로 미뤘다.

이름 변경 스크립트에는 안전장치를 넣었다. 자기 hostname을 매핑에서 찾아 변경하고, DC에서 실행되면 즉시 중단한다:

# DC 실행 방지 가드
if (Get-Service NTDS -ErrorAction SilentlyContinue) {
    Write-Host "이 머신은 도메인 컨트롤러입니다! 실행을 중단합니다." -ForegroundColor Red
    return
}
$renameMap = @{ "DESKTOP-T6MR55F" = "WIN11-CL01"; "DESKTOP-9BDUGO8" = "WIN10-CL01" }
$me = $env:COMPUTERNAME
if (-not $renameMap.ContainsKey($me)) { Write-Host "매핑 대상 아님: $me"; return }
Rename-Computer -NewName $renameMap[$me] -DomainCredential AZLAB\adm-jmpark -Restart -Force

이 가드가 왜 필요한지는 다음 글(DC 개명 사고 복구기)에서 뼈저리게 다룬다.

참고: 실행 시 “액세스 거부”가 나면 십중팔구 PowerShell 미승격이다. -DomainCredential은 AD 쪽 권한만 처리하고, 로컬 이름 변경 권한은 셸의 승격 상태를 따른다.

5. GPO 하나로 끝내기 — RDP + 접근 제어 + WinRM + 방화벽

GPO LAB-RDP-Allow 하나에 네 가지를 묶어 Workstations OU에 링크했다.

설정 방법 내용
RDP 연결 허용 레지스트리 정책 fDenyTSConnections=0 (클라이언트는 RDP 수신 기본 OFF)
RDP 접근 제어 Restricted Groups GG-Dev → 원격 데스크톱 사용자 (MemberOf 방식)
WinRM 원격관리 레지스트리 정책 + 서비스 자동 AllowAutoConfig=1, IPv4Filter=*
방화벽 레지스트리 정책 3389/5985/ICMPv4 인바운드 허용

두 가지 포인트:

① Restricted Groups는 “Member Of” 방식을 쓸 것. “Members” 방식은 그룹 멤버십을 통째로 덮어써서 기존 멤버가 날아간다. MemberOf는 기존 유지 + 추가만.

② Restricted Groups는 GPO cmdlet이 지원하지 않는다. GUI(GPMC)로 하는 게 일반적이지만, 스크립트로 하려면 SYSVOL의 GptTmpl.inf를 직접 생성하고 보안 CSE를 GPO 개체에 등록해야 한다:

$inf = @"
[Unicode]
Unicode=yes
[Version]
signature="`$CHICAGO`$"
Revision=1
[Group Membership]
*$ggDevSid`__Memberof = *S-1-5-32-555
*$ggDevSid`__Members =
[Service General Setting]
"WinRM",2,""
"@
Set-Content "$secEditPath\GptTmpl.inf" -Value $inf -Encoding Unicode   # UTF-16 필수

# 보안 CSE 등록 — 이걸 빼먹으면 클라이언트가 inf를 처리하지 않음
$secExt = "[{827D319E-6EAC-11D2-A4EA-00C04F79F83A}{803E14A0-B4FB-11D0-A0D0-00A0C90F574B}]"
Set-ADObject $gpoDN -Replace @{ gPCMachineExtensionNames = ($cur + $secExt) }

나머지 레지스트리 정책은 Set-GPRegistryValue로 깔끔하게 들어간다. 방화벽 규칙도 레지스트리 정책 문자열로 배포 가능:

Set-GPRegistryValue -Name "LAB-RDP-Allow" `
    -Key "HKLM\SOFTWARE\Policies\Microsoft\WindowsFirewall\FirewallRules" `
    -ValueName "LAB-RDP-In" -Type String `
    -Value "v2.31|Action=Allow|Active=TRUE|Dir=In|Protocol=6|LPort=3389|Name=LAB RDP Inbound|"

한 가지 사전 작업 — 조인 직후 컴퓨터는 CN=Computers 컨테이너에 있는데, 컨테이너에는 GPO를 링크할 수 없다. OU를 만들어 옮기는 것이 선행 조건이다.

6. 검증 — 3경로 권한 매트릭스

gpupdate 후 클라이언트에서 확인한 결과:

===== Remote Desktop Users (S-1-5-32-555) 구성원 =====
Name         ObjectClass PrincipalSource
----         ----------- ---------------
AZLAB\GG-Dev 그룹          ActiveDirectory

GptTmpl.inf 방식의 Restricted Groups가 정확히 동작했다. 실제 접속 테스트 결과:

접속 경로 user01/02/04 user03/05 (GG-Dev)
VM 콘솔 기본 세션 로그인 가능 로그인 가능
VM 콘솔 확장 세션 거부 로그인 가능
mstsc RDP 거부 로그인 가능

여기서 배운 디테일: Hyper-V 확장 세션은 내부적으로 RDP라서 RDP 권한 검사를 받는다. 확장 세션에서 “원격 로그인 권한” 오류가 나면 콘솔 문제가 아니라 RDP 권한 문제다. 기본 세션으로 전환하면 콘솔 로그인 취급이라 일반 사용자도 들어간다.

그리고 GPO 배포 후 DC에서 클라이언트 원격 관리도 개통됐다:

PS> Invoke-Command -ComputerName WIN10-CL01, WIN11-CL01 -ScriptBlock { hostname }
WIN10-CL01
WIN11-CL01

4절에서 0x800706BA로 막혔던 그 경로다. 이제 클라이언트가 몇 대가 되든 Workstations OU에 넣기만 하면 같은 상태가 된다 — 이게 GPO 중앙 관리의 실체다.

덤 하나: Test-NetConnection에서 IPv6 link-local ping 실패 WARNING이 떠도 PingSucceeded: True면 성공이다. GPO에 ICMPv4(Protocol 1)만 열었기 때문인데, “ping이 됐다 안 됐다 해요” 문의의 흔한 원인이 이 IPv6 우선 시도다.

7. 정리 — 오늘의 교훈

  1. 한글 포함 .ps1은 UTF-8 with BOM (PS5.1 대응)
  2. 로컬 그룹은 이름이 아니라 Well-known SID로 참조 — 표시 이름은 현지화되지만 SID(S-1-5-32-xxx)는 모든 Windows에서 동일
  3. 클라이언트는 원격 관리/ICMP 기본 차단 — GPO로 인프라화가 답
  4. Restricted Groups는 MemberOf 방식, 컨테이너에는 GPO 링크 불가
  5. 조인 위임은 정식 4종 세트(Reset Password + Account Restrictions + Validated Write 2종), 그리고 MAQ=0이어야 위임이 진짜 검증된다. dsacls가 이름 해석에서 막히면 GUID 기반 ACE가 확실한 방법 — 이름은 현지화·해석에 의존하지만 SID/GUID는 불변이다
  6. 원격/다중 머신 스크립트에는 실행 대상 가드를 넣을 것 (hostname 확인, DC 차단)

6번이 왜 굵은 글씨인지는 다음 글에서: 클라이언트 이름 변경 명령을 DC에서 실행해버린 사고와 그 복구 과정(netdom, 복제 오류 8524, _msdcs) 전체를 다룬다.

Comments