Skip to main content
This use case demonstrates how to use the functions scope to detect an authentication bypass in telnetd from GNU Inetutils. In telnetd, a _var_short_name function reads and returns the content of the USER environment variable without sanitizing it, as can be seen in the code below:
inetutils/telnetd/utility.c
char *
_var_short_name (struct line_expander *exp)
{
  char *q;
  char timebuf[64];
  time_t t;

  switch (*exp->cp++)
    {
    // ...
    case 'U':
      return getenv ("USER") ? xstrdup (getenv ("USER")) : xstrdup ("");

    default:
      exp->state = EXP_STATE_ERROR;
      return NULL;
    }
}
In this use case, it is assumed that _var_short_name is always inlined into _expand_var. This is normal if a function is called just once and from a single place, which is the case.

Finding _expand_var

To start the rule in the right context, the functions scope can be used like this:
scopes = scope:functions{
  target = "_expand_var",
  with = check
}
This will find the _expand_var function in the binary. As the function name is fixed in this case, there’s no need to use target = {matching = "<regex>"}. Because scope:functions was used, the check function receives two parameters, of types ProjectHandle and FunctionContext, in that order. The first gives access to project-level methods, while the second holds the context of the matched function — _expand_var in this case — and provides access to function-level methods.

Finding getenv

A good rule should annotate relevant addresses precisely. To find the call to getenv, one possible check function is as follows:
function check(project, context)
  -- In the source code there are two calls to `getenv`, but the compiler may
  -- optimize this to a single call. To cover both cases, `context:calls("imp.getenv")`
  -- was used to return ALL calls to `getenv` from the context.
  --
  -- When searching for an `imp.`-prefixed function name, VulHunt automatically
  -- searches for the unprefixed name if `imp.gentev` is not found, thus for
  -- simple cases like this, regular expressions are not needed.
  local getenv_calls = context:calls("imp.getenv")

  -- Arrays in Lua starts at 1, so this line gets the first call
  -- to `getenv` recovered by VulHunt
  local getenv = getenv_calls[1]

  -- If there are two calls to `getenv`, the rule needs to find the second as
  -- the first is only used in the condition check from the source code.
  --
  -- For performance reasons, VulHunt does not guarantee the order of the
  -- returned functions. Therefore, `context:precedes` is used to check
  -- whether the first returned function precedes the second.
  if #getenv_calls == 2 and context:precedes(getenv_calls[1], getenv_calls[2]) then
    getenv = getenv_calls[2]
  end
  -- Keep working with `getenv`
end

Finding start_login and its call to execv

After reading the contents of USER, a start_login function passes it to login(1) (by default), which has a -f <username> option to skip login authentication for a given <username>. Consequently, if an attacker sets USER to -f root, the authentication is bypassed and the attacker will be logged as root. The relevant code is as follows:
inetutils/telnetd/pty.c
void
start_login (char *host, int autologin, char *name)
{
  char *cmd;
  int argc;
  char **argv;
  // --snip--

  // `expand_line` indirectly calls `_expand_var`
  cmd = expand_line (login_invocation);
  if (!cmd)
    fatal (net, "can't expand login command line");
  argcv_get (cmd, "", &argc, &argv);
  execv (argv[0], argv);
  syslog (LOG_ERR, "%s: %m\n", cmd);
  fatalperror (net, cmd);
}
Because the rule is at the function level of _expand_var, it’s necessary to look at the project level to find start_login in the code. The following code uses a method from projectcheck’s first parameter — to achieve it:
function check(project, context)
  -- Previous code to find `getenv`
  local getenv_calls = context:calls("imp.getenv")
  local getenv = getenv_calls[1]

  if #getenv_calls == 2 and context:precedes(getenv_calls[1], getenv_calls[2]) then
    getenv = getenv_calls[2]
  end

  -- New code to find `start_login`
  local start_login = project:functions("start_login")

  if not start_login then
    return
  end

  -- At this point, `start_login` has type `FunctionContext`, just like
  -- the `context` parameter of this `check` function. Therefore, the
  -- same methods used to locate `getenv` can also be used to locate `execv`.

  local execv_calls = start_login:calls("imp.execv")
  -- Keep working with `execv_calls`
end

Returning the evidence

Below is the complete check function to detect this vulnerability. It includes a check for possible calls to strcspn from _expand_var, which are added with the patch. Production-level rules may use a result:patch method to properly inform the binary is patched. Check the Scopes Result Overview in the VulHunt Reference for more information.
telnetd.vh
function check(project, context)
    -- Check for the patch: calls to `strcspn` from `_expand_var`
    if context:has_call("strcspn") then
        print("patched")
        return
    end

    local getenv_calls = context:calls("imp.getenv")
    local getenv = getenv_calls[1]

    if #getenv_calls == 2 and context:precedes(getenv_calls[1], getenv_calls[2]) then
        getenv = getenv_calls[2]
    end

    local start_login = project:functions("start_login")

    if not start_login then return end

    local execv_calls = start_login:calls("imp.execv")

    if not next(execv_calls) then return end

    local execv = execv_calls[1]

    return result:critical{
        name = "CVE-2026-24061",
        description = "In GNU Inetutils 1.9.3 to 2.7, telnetd passes the unsanitized USER environment variable ('-f root') to login, enabling remote authentication bypass",
        evidence = {
            functions = {
                [context.address] = {
                    annotate:prototype "char *_expand_var (struct line_expander *exp)",
                    annotate:at{
                        location = getenv,
                        message = "This call to `getenv` reads the contents of the `USER` environment variable and no sanitization is done afterwards."
                    }
                },
                [start_login.address] = {
                    annotate:prototype "void start_login(char *host, int autologin, char *name)",
                    annotate:at{
                        location = execv,
                        message = "This call to `execv` executes the login(1) program by default with the value previously read from the `USER`\nenvironment variable as its last parameter. A value of \"-f root\" results in an authentication bypass."
                    }
                }
            }
        }
    }
end
VulHunt rules are versatile. Instead of using scope:functions with matching to locate _expand_var, an analyst could target start_login and adapt the remaining logic to identify other functions. There are multiple ways to achieve the same goal.