Exec

Implementation Goal: tinydbg exec ./prog

This section introduces the exec command for starting debugging: tinydbg exec [executable] [flags]. The exec operation will execute the executable and automatically attach to the corresponding process. In Chapter 6 where we introduced instruction-level debugging, we demonstrated how to specify the program to launch using exec.Command, how to start the program, and how to automatically trace it with ptrace after launch. If you've forgotten this part, you can review sections 6.1, 6.2, and 6.3.

The exec command in demo tinydbg is essentially revisiting familiar territory, except that tinydbg uses a frontend-backend separated architecture. If we only consider the target layer's control of the tracee in the backend, the key points to note are the same.

$ tinydbg help exec
Execute a precompiled binary and begin a debug session.

This command will cause Delve to exec the binary and immediately attach to it to
begin a new debug session. Please note that if the binary was not compiled with
optimizations disabled, it may be difficult to properly debug it. Please
consider compiling debugging binaries with -gcflags="all=-N -l" on Go 1.10
or later, -gcflags="-N -l" on earlier versions of Go.

Usage:
  tinydbg exec <path/to/binary> [flags]

Flags:
      --continue     Continue the debugged process on start.
  -h, --help         help for exec
      --tty string   TTY to use for the target program

Global Flags:
      --accept-multiclient               Allows a headless server to accept multiple client connections via JSON-RPC.
      --allow-non-terminal-interactive   Allows interactive sessions of Delve that don't have a terminal as stdin, stdout and stderr
      --disable-aslr                     Disables address space randomization
      --headless                         Run debug server only, in headless mode. Server will accept JSON-RPC client connections.
      --init string                      Init file, executed by the terminal client.
  -l, --listen string                    Debugging server listen address. Prefix with 'unix:' to use a unix domain socket. (default "127.0.0.1:0")
      --log                              Enable debugging server logging.
      --log-dest string                  Writes logs to the specified file or file descriptor (see 'dlv help log').
      --log-output string                Comma separated list of components that should produce debug output (see 'dlv help log')
  -r, --redirect stringArray             Specifies redirect rules for target process (see 'dlv help redirect')
      --wd string                        Working directory for running the program.

Compared to the attach operation, the exec operation adds a --disable-aslr option. We'll only introduce this option here, as other options were covered when discussing the attach operation. OK, we introduced ASLR in Chapter 6 on instruction-level debugging. This feature is rarely used, so let's mention it again.

ASLR is an operating system-level security technology that primarily works by randomizing the memory loading positions of programs to increase the difficulty for attackers to predict target addresses and exploit software vulnerabilities. Its core mechanism includes dynamically randomizing the positions of various parts in the process address space, such as executable base addresses, library files, heap, and stack. The Linux kernel enables full address randomization by default, but for executable address randomization, PIE compilation mode must be enabled. While this brings certain security benefits, if you want to perform automated debugging tasks that use instruction addresses for certain operations, ASLR might cause debugging to fail.

Therefore, an option --disable-aslr is added here, which will disable all the address space randomization capabilities mentioned above.

Basic Knowledge

Code Implementation

The main code execution path is as follows:

main.go:main.main
    \--> cmds.New(false).Execute()
            \--> execCommand.Run()
                    \--> execute(0, args, conf, "", debugger.ExecutingExistingFile, args, buildFlags)
                            \--> server := rpccommon.NewServer(...)
                            \--> server.Run()
                                    \--> debugger, _ := debugger.New(...)
                                            if attach startup: debugger.Attach(...)
                                            elif core startup: core.OpenCore(...)
                                            else others debuger.Launch(...)
                                    \--> c, _ := listener.Accept() 
                                    \--> serveConnection(conn)

Since we've already covered the debugger backend initialization logic, including network communication initialization and debugger initialization, we'll focus directly on the core code here.

For the exec startup method, let's look at the implementation of debugger.Launch(...):

// Launch will start a process with the given args and working directory.
func (d *Debugger) Launch(processArgs []string, wd string) (*proc.TargetGroup, error) {
    ...

    launchFlags := proc.LaunchFlags(0)
    if d.config.DisableASLR {
        launchFlags |= proc.LaunchDisableASLR
    }
    ...

    return native.Launch(processArgs, wd, launchFlags, d.config.TTY, d.config.Stdin, d.config.Stdout, d.config.Stderr)
}

func Launch(cmd []string, wd string, flags proc.LaunchFlags, tty string, stdinPath string, stdoutOR proc.OutputRedirect, stderrOR proc.OutputRedirect) (*proc.TargetGroup, error) {
    ...

    // Input/output redirection setup
    stdin, stdout, stderr, closefn, err := openRedirects(stdinPath, stdoutOR, stderrOR, foreground)
    if err != nil {
        return nil, err
    }
    ...

    dbp := newProcess(0)
    ...
    dbp.execPtraceFunc(func() {
        // Use personality system call to disable address space randomization (only affects current process and its children)
        // Then start our program to be debugged, which will now have address space randomization disabled
        if flags&proc.LaunchDisableASLR != 0 {
            oldPersonality, _, err := syscall.Syscall(sys.SYS_PERSONALITY, personalityGetPersonality, 0, 0)
            if err == syscall.Errno(0) {
                newPersonality := oldPersonality | _ADDR_NO_RANDOMIZE
                syscall.Syscall(sys.SYS_PERSONALITY, newPersonality, 0, 0)
                defer syscall.Syscall(sys.SYS_PERSONALITY, oldPersonality, 0, 0)
            }
        }

        // Start the program to be debugged, which now has address space randomization disabled
        process = exec.Command(cmd[0])
        process.Args = cmd
        process.Stdin = stdin
        process.Stdout = stdout
        process.Stderr = stderr
        process.SysProcAttr = &syscall.SysProcAttr{
            // Ptrace=true, in the Go standard library, PTRACEME will be called in the child process
            Ptrace:     true, 
            Setpgid:    true,
            Foreground: foreground,
        }
        ...
        err = process.Start()
    })

    // Wait for tracee to start
    dbp.pid = process.Process.Pid
    dbp.childProcess = true
    _, _, err = dbp.wait(process.Process.Pid, 0)

    // Further initialization, including bringing all existing threads and future threads under control
    tgt, err := dbp.initialize(cmd[0])
    if err != nil {
        return nil, err
    }
    return tgt, nil
}

see go/src/syscall/exec_linux.go

func forkAndExecInChild1(...) {
    ...
    if sys.Ptrace {
        _, _, err1 = RawSyscall(SYS_PTRACE, uintptr(PTRACE_TRACEME), 0, 0)
        if err1 != 0 {
            goto childerror
        }
    }
    ...

This completes the target layer logic of the exec operation in the debugger backend. After the frontend-backend network I/O initialization is complete, the frontend can send debugging commands through the debugging session.

Testing

Omitted

Summary

This article introduced the implementation details of the tinydbg exec command. The exec command is used to start a new process and debug it, mainly implemented by setting the process's SysProcAttr.Ptrace=true. When the new process starts, the Go runtime automatically calls PTRACE_TRACEME to put the child process into a traced state. The debugger waits for the child process to start, then brings all its threads under control. This completes the target layer logic of the exec operation, preparing for subsequent debugging sessions.

We also reviewed the role of ASLR and its impact on debugging, and introduced the method of using --disable-aslr.

results matching ""

    No results matching ""