xref: /linux/tools/testing/selftests/tty/tty_tiocsti_test.c (revision b61104e7a6349bd2c2b3e2fb3260d87f15eda8f4)
1 // SPDX-License-Identifier: GPL-2.0
2 /*
3  * TTY Tests - TIOCSTI
4  *
5  * Copyright © 2025 Abhinav Saxena <xandfury@gmail.com>
6  */
7 
8 #include <stdio.h>
9 #include <stdlib.h>
10 #include <unistd.h>
11 #include <fcntl.h>
12 #include <sys/ioctl.h>
13 #include <errno.h>
14 #include <stdbool.h>
15 #include <string.h>
16 #include <sys/socket.h>
17 #include <sys/wait.h>
18 #include <pwd.h>
19 #include <termios.h>
20 #include <grp.h>
21 #include <sys/capability.h>
22 #include <sys/prctl.h>
23 #include <pty.h>
24 #include <utmp.h>
25 
26 #include "../kselftest_harness.h"
27 
28 enum test_type {
29 	TEST_PTY_TIOCSTI_BASIC,
30 	TEST_PTY_TIOCSTI_FD_PASSING,
31 	/* other tests cases such as serial may be added. */
32 };
33 
34 /*
35  * Test Strategy:
36  * - Basic tests: Use PTY with/without TIOCSCTTY (controlling terminal for
37  *   current process)
38  * - FD passing tests: Child creates PTY, parent receives FD (demonstrates
39  *   security issue)
40  *
41  * SECURITY VULNERABILITY DEMONSTRATION:
42  * FD passing tests show that TIOCSTI uses CURRENT process credentials, not
43  * opener credentials. This means privileged processes can be given FDs from
44  * unprivileged processes and successfully perform TIOCSTI operations that the
45  * unprivileged process couldn't do directly.
46  *
47  * Attack scenario:
48  * 1. Unprivileged process opens TTY (direct TIOCSTI fails due to lack of
49  *    privileges)
50  * 2. Unprivileged process passes FD to privileged process via SCM_RIGHTS
51  * 3. Privileged process can use TIOCSTI on the FD (succeeds due to its
52  *    privileges)
53  * 4. Result: Effective privilege escalation via file descriptor passing
54  *
55  * This matches the kernel logic in tiocsti():
56  * 1. if (!tty_legacy_tiocsti && !capable(CAP_SYS_ADMIN)) return -EIO;
57  * 2. if ((current->signal->tty != tty) && !capable(CAP_SYS_ADMIN))
58  *        return -EPERM;
59  * Note: Both checks use capable() on CURRENT process, not FD opener!
60  *
61  * If the file credentials were also checked along with the capable() checks
62  * then the results for FD pass tests would be consistent with the basic tests.
63  */
64 
65 FIXTURE(tiocsti)
66 {
67 	int pty_master_fd; /* PTY - for basic tests */
68 	int pty_slave_fd;
69 	bool has_pty;
70 	bool initial_cap_sys_admin;
71 	int original_legacy_tiocsti_setting;
72 	bool can_modify_sysctl;
73 };
74 
75 FIXTURE_VARIANT(tiocsti)
76 {
77 	const enum test_type test_type;
78 	const bool controlling_tty; /* true=current->signal->tty == tty */
79 	const int legacy_tiocsti; /* 0=restricted, 1=permissive */
80 	const bool requires_cap; /* true=with CAP_SYS_ADMIN, false=without */
81 	const int expected_success; /* 0=success, -EIO/-EPERM=specific error */
82 };
83 
84 /*
85  * Tests Controlling Terminal Variants (current->signal->tty == tty)
86  *
87  * TIOCSTI Test Matrix:
88  *
89  * | legacy_tiocsti | CAP_SYS_ADMIN | Expected Result | Error |
90  * |----------------|---------------|-----------------|-------|
91  * | 1 (permissive) | true          | SUCCESS         | -     |
92  * | 1 (permissive) | false         | SUCCESS         | -     |
93  * | 0 (restricted) | true          | SUCCESS         | -     |
94  * | 0 (restricted) | false         | FAILURE         | -EIO  |
95  */
96 
97 /* clang-format off */
98 FIXTURE_VARIANT_ADD(tiocsti, basic_pty_permissive_withcap) {
99 	.test_type = TEST_PTY_TIOCSTI_BASIC,
100 	.controlling_tty = true,
101 	.legacy_tiocsti = 1,
102 	.requires_cap = true,
103 	.expected_success = 0,
104 };
105 
106 FIXTURE_VARIANT_ADD(tiocsti, basic_pty_permissive_nocap) {
107 	.test_type = TEST_PTY_TIOCSTI_BASIC,
108 	.controlling_tty = true,
109 	.legacy_tiocsti = 1,
110 	.requires_cap = false,
111 	.expected_success = 0,
112 };
113 
114 FIXTURE_VARIANT_ADD(tiocsti, basic_pty_restricted_withcap) {
115 	.test_type = TEST_PTY_TIOCSTI_BASIC,
116 	.controlling_tty = true,
117 	.legacy_tiocsti = 0,
118 	.requires_cap = true,
119 	.expected_success = 0,
120 };
121 
122 FIXTURE_VARIANT_ADD(tiocsti, basic_pty_restricted_nocap) {
123 	.test_type = TEST_PTY_TIOCSTI_BASIC,
124 	.controlling_tty = true,
125 	.legacy_tiocsti = 0,
126 	.requires_cap = false,
127 	.expected_success = -EIO, /* FAILURE: legacy restriction */
128 }; /* clang-format on */
129 
130 /*
131  * Note for FD Passing Test Variants
132  * Since we're testing the scenario where an unprivileged process pass an FD
133  * to a privileged one, .requires_cap here means the caps of the child process.
134  * Not the parent; parent would always be privileged.
135  */
136 
137 /* clang-format off */
138 FIXTURE_VARIANT_ADD(tiocsti, fdpass_pty_permissive_withcap) {
139 	.test_type = TEST_PTY_TIOCSTI_FD_PASSING,
140 	.controlling_tty = true,
141 	.legacy_tiocsti = 1,
142 	.requires_cap = true,
143 	.expected_success = 0,
144 };
145 
146 FIXTURE_VARIANT_ADD(tiocsti, fdpass_pty_permissive_nocap) {
147 	.test_type = TEST_PTY_TIOCSTI_FD_PASSING,
148 	.controlling_tty = true,
149 	.legacy_tiocsti = 1,
150 	.requires_cap = false,
151 	.expected_success = 0,
152 };
153 
154 FIXTURE_VARIANT_ADD(tiocsti, fdpass_pty_restricted_withcap) {
155 	.test_type = TEST_PTY_TIOCSTI_FD_PASSING,
156 	.controlling_tty = true,
157 	.legacy_tiocsti = 0,
158 	.requires_cap = true,
159 	.expected_success = 0,
160 };
161 
162 FIXTURE_VARIANT_ADD(tiocsti, fdpass_pty_restricted_nocap) {
163 	.test_type = TEST_PTY_TIOCSTI_FD_PASSING,
164 	.controlling_tty = true,
165 	.legacy_tiocsti = 0,
166 	.requires_cap = false,
167 	.expected_success = -EIO,
168 }; /* clang-format on */
169 
170 /*
171  * Non-Controlling Terminal Variants (current->signal->tty != tty)
172  *
173  * TIOCSTI Test Matrix:
174  *
175  * | legacy_tiocsti | CAP_SYS_ADMIN | Expected Result | Error |
176  * |----------------|---------------|-----------------|-------|
177  * | 1 (permissive) | true          | SUCCESS         | -     |
178  * | 1 (permissive) | false         | FAILURE         | -EPERM|
179  * | 0 (restricted) | true          | SUCCESS         | -     |
180  * | 0 (restricted) | false         | FAILURE         | -EIO  |
181  */
182 
183 /* clang-format off */
184 FIXTURE_VARIANT_ADD(tiocsti, basic_nopty_permissive_withcap) {
185 	.test_type = TEST_PTY_TIOCSTI_BASIC,
186 	.controlling_tty = false,
187 	.legacy_tiocsti = 1,
188 	.requires_cap = true,
189 	.expected_success = 0,
190 };
191 
192 FIXTURE_VARIANT_ADD(tiocsti, basic_nopty_permissive_nocap) {
193 	.test_type = TEST_PTY_TIOCSTI_BASIC,
194 	.controlling_tty = false,
195 	.legacy_tiocsti = 1,
196 	.requires_cap = false,
197 	.expected_success = -EPERM,
198 };
199 
200 FIXTURE_VARIANT_ADD(tiocsti, basic_nopty_restricted_withcap) {
201 	.test_type = TEST_PTY_TIOCSTI_BASIC,
202 	.controlling_tty = false,
203 	.legacy_tiocsti = 0,
204 	.requires_cap = true,
205 	.expected_success = 0,
206 };
207 
208 FIXTURE_VARIANT_ADD(tiocsti, basic_nopty_restricted_nocap) {
209 	.test_type = TEST_PTY_TIOCSTI_BASIC,
210 	.controlling_tty = false,
211 	.legacy_tiocsti = 0,
212 	.requires_cap = false,
213 	.expected_success = -EIO,
214 };
215 
216 FIXTURE_VARIANT_ADD(tiocsti, fdpass_nopty_permissive_withcap) {
217 	.test_type = TEST_PTY_TIOCSTI_FD_PASSING,
218 	.controlling_tty = false,
219 	.legacy_tiocsti = 1,
220 	.requires_cap = true,
221 	.expected_success = 0,
222 };
223 
224 FIXTURE_VARIANT_ADD(tiocsti, fdpass_nopty_permissive_nocap) {
225 	.test_type = TEST_PTY_TIOCSTI_FD_PASSING,
226 	.controlling_tty = false,
227 	.legacy_tiocsti = 1,
228 	.requires_cap = false,
229 	.expected_success = -EPERM,
230 };
231 
232 FIXTURE_VARIANT_ADD(tiocsti, fdpass_nopty_restricted_withcap) {
233 	.test_type = TEST_PTY_TIOCSTI_FD_PASSING,
234 	.controlling_tty = false,
235 	.legacy_tiocsti = 0,
236 	.requires_cap = true,
237 	.expected_success = 0,
238 };
239 
240 FIXTURE_VARIANT_ADD(tiocsti, fdpass_nopty_restricted_nocap) {
241 	.test_type = TEST_PTY_TIOCSTI_FD_PASSING,
242 	.controlling_tty = false,
243 	.legacy_tiocsti = 0,
244 	.requires_cap = false,
245 	.expected_success = -EIO,
246 }; /* clang-format on */
247 
248 /* Helper function to send FD via SCM_RIGHTS */
249 static int send_fd_via_socket(int socket_fd, int fd_to_send)
250 {
251 	struct msghdr msg = { 0 };
252 	struct cmsghdr *cmsg;
253 	char cmsg_buf[CMSG_SPACE(sizeof(int))];
254 	char dummy_data = 'F';
255 	struct iovec iov = { .iov_base = &dummy_data, .iov_len = 1 };
256 
257 	msg.msg_iov = &iov;
258 	msg.msg_iovlen = 1;
259 	msg.msg_control = cmsg_buf;
260 	msg.msg_controllen = sizeof(cmsg_buf);
261 
262 	cmsg = CMSG_FIRSTHDR(&msg);
263 	cmsg->cmsg_level = SOL_SOCKET;
264 	cmsg->cmsg_type = SCM_RIGHTS;
265 	cmsg->cmsg_len = CMSG_LEN(sizeof(int));
266 
267 	memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int));
268 
269 	return sendmsg(socket_fd, &msg, 0) < 0 ? -1 : 0;
270 }
271 
272 /* Helper function to receive FD via SCM_RIGHTS */
273 static int recv_fd_via_socket(int socket_fd)
274 {
275 	struct msghdr msg = { 0 };
276 	struct cmsghdr *cmsg;
277 	char cmsg_buf[CMSG_SPACE(sizeof(int))];
278 	char dummy_data;
279 	struct iovec iov = { .iov_base = &dummy_data, .iov_len = 1 };
280 	int received_fd = -1;
281 
282 	msg.msg_iov = &iov;
283 	msg.msg_iovlen = 1;
284 	msg.msg_control = cmsg_buf;
285 	msg.msg_controllen = sizeof(cmsg_buf);
286 
287 	if (recvmsg(socket_fd, &msg, 0) < 0)
288 		return -1;
289 
290 	for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
291 		if (cmsg->cmsg_level == SOL_SOCKET &&
292 		    cmsg->cmsg_type == SCM_RIGHTS) {
293 			memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(int));
294 			break;
295 		}
296 	}
297 
298 	return received_fd;
299 }
300 
301 static inline bool has_cap_sys_admin(void)
302 {
303 	cap_t caps = cap_get_proc();
304 
305 	if (!caps)
306 		return false;
307 
308 	cap_flag_value_t cap_val;
309 	bool has_cap = (cap_get_flag(caps, CAP_SYS_ADMIN, CAP_EFFECTIVE,
310 				     &cap_val) == 0) &&
311 		       (cap_val == CAP_SET);
312 
313 	cap_free(caps);
314 	return has_cap;
315 }
316 
317 /*
318  * Switch to non-root user and clear all capabilities
319  */
320 static inline bool drop_all_privs(struct __test_metadata *_metadata)
321 {
322 	/* Drop supplementary groups */
323 	ASSERT_EQ(setgroups(0, NULL), 0);
324 
325 	/* Switch to non-root user */
326 	ASSERT_EQ(setgid(1000), 0);
327 	ASSERT_EQ(setuid(1000), 0);
328 
329 	/* Clear all capabilities */
330 	cap_t empty = cap_init();
331 
332 	ASSERT_NE(empty, NULL);
333 	ASSERT_EQ(cap_set_proc(empty), 0);
334 	cap_free(empty);
335 
336 	/* Prevent privilege regain */
337 	ASSERT_EQ(prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0), 0);
338 
339 	/* Verify privilege drop */
340 	ASSERT_FALSE(has_cap_sys_admin());
341 	return true;
342 }
343 
344 static inline int get_legacy_tiocsti_setting(struct __test_metadata *_metadata)
345 {
346 	FILE *fp;
347 	int value = -1;
348 
349 	fp = fopen("/proc/sys/dev/tty/legacy_tiocsti", "r");
350 	if (!fp) {
351 		/* legacy_tiocsti sysctl not available (kernel < 6.2) */
352 		return -1;
353 	}
354 
355 	if (fscanf(fp, "%d", &value) == 1 && fclose(fp) == 0) {
356 		if (value < 0 || value > 1)
357 			value = -1; /* Invalid value */
358 	} else {
359 		value = -1; /* Failed to parse */
360 	}
361 
362 	return value;
363 }
364 
365 static inline bool set_legacy_tiocsti_setting(struct __test_metadata *_metadata,
366 					      int value)
367 {
368 	FILE *fp;
369 	bool success = false;
370 
371 	/* Sanity-check the value */
372 	ASSERT_GE(value, 0);
373 	ASSERT_LE(value, 1);
374 
375 	/*
376 	 * Try to open for writing; if we lack permission, return false so
377 	 * the test harness will skip variants that need to change it
378 	 */
379 	fp = fopen("/proc/sys/dev/tty/legacy_tiocsti", "w");
380 	if (!fp)
381 		return false;
382 
383 	/* Write the new setting */
384 	if (fprintf(fp, "%d\n", value) > 0 && fclose(fp) == 0)
385 		success = true;
386 	else
387 		TH_LOG("Failed to write legacy_tiocsti: %s", strerror(errno));
388 
389 	return success;
390 }
391 
392 /*
393  * TIOCSTI injection test function
394  * @tty_fd: TTY slave file descriptor to test TIOCSTI on
395  * Returns: 0 on success, -errno on failure
396  */
397 static inline int test_tiocsti_injection(struct __test_metadata *_metadata,
398 					 int tty_fd)
399 {
400 	int ret;
401 	char inject_char = 'V';
402 
403 	errno = 0;
404 	ret = ioctl(tty_fd, TIOCSTI, &inject_char);
405 	return ret == 0 ? 0 : -errno;
406 }
407 
408 /*
409  * Child process: test TIOCSTI directly with capability/controlling
410  * terminal setup
411  */
412 static void run_basic_tiocsti_test(struct __test_metadata *_metadata,
413 				   FIXTURE_DATA(tiocsti) * self,
414 				   const FIXTURE_VARIANT(tiocsti) * variant)
415 {
416 	/* Handle capability requirements */
417 	if (self->initial_cap_sys_admin && !variant->requires_cap)
418 		ASSERT_TRUE(drop_all_privs(_metadata));
419 
420 	if (variant->controlling_tty) {
421 		/*
422 		 * Create new session and set PTY as
423 		 * controlling terminal
424 		 */
425 		pid_t sid = setsid();
426 
427 		ASSERT_GE(sid, 0);
428 		ASSERT_EQ(ioctl(self->pty_slave_fd, TIOCSCTTY, 0), 0);
429 	}
430 
431 	/*
432 	 * Validate test environment setup and verify final
433 	 * capability state matches expectation
434 	 * after potential drop.
435 	 */
436 	ASSERT_TRUE(self->has_pty);
437 	ASSERT_EQ(has_cap_sys_admin(), variant->requires_cap);
438 
439 	/* Test TIOCSTI and validate result */
440 	int result = test_tiocsti_injection(_metadata, self->pty_slave_fd);
441 
442 	/* Check against expected result from variant */
443 	EXPECT_EQ(result, variant->expected_success);
444 	_exit(0);
445 }
446 
447 /*
448  * Child process: create PTY and then pass FD to parent via SCM_RIGHTS
449  */
450 static void run_fdpass_tiocsti_test(struct __test_metadata *_metadata,
451 				    const FIXTURE_VARIANT(tiocsti) * variant,
452 				    int sockfd)
453 {
454 	signal(SIGHUP, SIG_IGN);
455 
456 	/* Handle privilege dropping */
457 	if (!variant->requires_cap && has_cap_sys_admin())
458 		ASSERT_TRUE(drop_all_privs(_metadata));
459 
460 	/* Create child's PTY */
461 	int child_master_fd, child_slave_fd;
462 
463 	ASSERT_EQ(openpty(&child_master_fd, &child_slave_fd, NULL, NULL, NULL),
464 		  0);
465 
466 	if (variant->controlling_tty) {
467 		pid_t sid = setsid();
468 
469 		ASSERT_GE(sid, 0);
470 		ASSERT_EQ(ioctl(child_slave_fd, TIOCSCTTY, 0), 0);
471 	}
472 
473 	/* Test child's direct TIOCSTI for reference */
474 	int direct_result = test_tiocsti_injection(_metadata, child_slave_fd);
475 
476 	EXPECT_EQ(direct_result, variant->expected_success);
477 
478 	/* Send FD to parent */
479 	ASSERT_EQ(send_fd_via_socket(sockfd, child_slave_fd), 0);
480 
481 	/* Wait for parent completion signal */
482 	char sync_byte;
483 	ssize_t bytes_read = read(sockfd, &sync_byte, 1);
484 
485 	ASSERT_EQ(bytes_read, 1);
486 
487 	close(child_master_fd);
488 	close(child_slave_fd);
489 	close(sockfd);
490 	_exit(0);
491 }
492 
493 FIXTURE_SETUP(tiocsti)
494 {
495 	/* Create PTY pair for basic tests */
496 	self->has_pty = (openpty(&self->pty_master_fd, &self->pty_slave_fd,
497 				 NULL, NULL, NULL) == 0);
498 	if (!self->has_pty) {
499 		self->pty_master_fd = -1;
500 		self->pty_slave_fd = -1;
501 	}
502 
503 	self->initial_cap_sys_admin = has_cap_sys_admin();
504 	self->original_legacy_tiocsti_setting =
505 		get_legacy_tiocsti_setting(_metadata);
506 
507 	if (self->original_legacy_tiocsti_setting < 0)
508 		SKIP(return,
509 			   "legacy_tiocsti sysctl not available (kernel < 6.2)");
510 
511 	/* Common skip conditions */
512 	if (variant->test_type == TEST_PTY_TIOCSTI_BASIC && !self->has_pty)
513 		SKIP(return, "PTY not available for controlling terminal test");
514 
515 	if (variant->test_type == TEST_PTY_TIOCSTI_FD_PASSING &&
516 	    !self->initial_cap_sys_admin)
517 		SKIP(return, "FD Pass tests require CAP_SYS_ADMIN");
518 
519 	if (variant->requires_cap && !self->initial_cap_sys_admin)
520 		SKIP(return, "Test requires initial CAP_SYS_ADMIN");
521 
522 	/* Test if we can modify the sysctl (requires appropriate privileges) */
523 	self->can_modify_sysctl = set_legacy_tiocsti_setting(
524 		_metadata, self->original_legacy_tiocsti_setting);
525 
526 	/* Sysctl setup based on variant */
527 	if (self->can_modify_sysctl &&
528 	    self->original_legacy_tiocsti_setting != variant->legacy_tiocsti) {
529 		if (!set_legacy_tiocsti_setting(_metadata,
530 						variant->legacy_tiocsti))
531 			SKIP(return, "Failed to set legacy_tiocsti sysctl");
532 
533 	} else if (!self->can_modify_sysctl &&
534 		   self->original_legacy_tiocsti_setting !=
535 			   variant->legacy_tiocsti)
536 		SKIP(return, "legacy_tiocsti setting mismatch");
537 }
538 
539 FIXTURE_TEARDOWN(tiocsti)
540 {
541 	/*
542 	 * Backup restoration -
543 	 * each test should restore its own sysctl changes
544 	 */
545 	if (self->can_modify_sysctl) {
546 		int current_value = get_legacy_tiocsti_setting(_metadata);
547 
548 		if (current_value != self->original_legacy_tiocsti_setting) {
549 			TH_LOG("Backup: Restoring legacy_tiocsti from %d to %d",
550 			       current_value,
551 			       self->original_legacy_tiocsti_setting);
552 			set_legacy_tiocsti_setting(
553 				_metadata,
554 				self->original_legacy_tiocsti_setting);
555 		}
556 	}
557 
558 	if (self->has_pty) {
559 		if (self->pty_master_fd >= 0)
560 			close(self->pty_master_fd);
561 		if (self->pty_slave_fd >= 0)
562 			close(self->pty_slave_fd);
563 	}
564 }
565 
566 TEST_F(tiocsti, test)
567 {
568 	int status;
569 	pid_t child_pid;
570 
571 	if (variant->test_type == TEST_PTY_TIOCSTI_BASIC) {
572 		/* ===== BASIC TIOCSTI TEST ===== */
573 		child_pid = fork();
574 		ASSERT_GE(child_pid, 0);
575 
576 		/* Perform the actual test in the child process */
577 		if (child_pid == 0)
578 			run_basic_tiocsti_test(_metadata, self, variant);
579 
580 	} else {
581 		/* ===== FD PASSING SECURITY TEST ===== */
582 		int sockpair[2];
583 
584 		ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, sockpair), 0);
585 
586 		child_pid = fork();
587 		ASSERT_GE(child_pid, 0);
588 
589 		if (child_pid == 0) {
590 			/* Child process - create PTY and send FD */
591 			close(sockpair[0]);
592 			run_fdpass_tiocsti_test(_metadata, variant,
593 						sockpair[1]);
594 		}
595 
596 		/* Parent process - receive FD and test TIOCSTI */
597 		close(sockpair[1]);
598 
599 		int received_fd = recv_fd_via_socket(sockpair[0]);
600 
601 		ASSERT_GE(received_fd, 0);
602 
603 		bool parent_has_cap = self->initial_cap_sys_admin;
604 
605 		TH_LOG("=== TIOCSTI FD Passing Test Context ===");
606 		TH_LOG("legacy_tiocsti: %d, Parent CAP_SYS_ADMIN: %s, Child: %s",
607 		       variant->legacy_tiocsti, parent_has_cap ? "yes" : "no",
608 		       variant->requires_cap ? "kept" : "dropped");
609 
610 		/* SECURITY TEST: Try TIOCSTI with FD opened by child */
611 		int result = test_tiocsti_injection(_metadata, received_fd);
612 
613 		/* Log security concern if demonstrated */
614 		if (result == 0 && !variant->requires_cap) {
615 			TH_LOG("*** SECURITY CONCERN DEMONSTRATED ***");
616 			TH_LOG("Privileged parent can use TIOCSTI on FD from unprivileged child");
617 			TH_LOG("This shows current process credentials are used, not opener credentials");
618 		}
619 
620 		EXPECT_EQ(result, variant->expected_success)
621 		{
622 			TH_LOG("FD passing: expected error %d, got %d",
623 			       variant->expected_success, result);
624 		}
625 
626 		/* Signal child completion */
627 		char sync_byte = 'D';
628 		ssize_t bytes_written = write(sockpair[0], &sync_byte, 1);
629 
630 		ASSERT_EQ(bytes_written, 1);
631 
632 		close(received_fd);
633 		close(sockpair[0]);
634 	}
635 
636 	/* Common child process cleanup for both test types */
637 	ASSERT_EQ(waitpid(child_pid, &status, 0), child_pid);
638 
639 	if (WIFSIGNALED(status)) {
640 		TH_LOG("Child terminated by signal %d", WTERMSIG(status));
641 		ASSERT_FALSE(WIFSIGNALED(status))
642 		{
643 			TH_LOG("Child process failed assertion");
644 		}
645 	} else {
646 		EXPECT_EQ(WEXITSTATUS(status), 0);
647 	}
648 }
649 
650 TEST_HARNESS_MAIN
651