|
| StreamSec Tools 4.1.1.354 - manual fix P01 The SFTP server follows a junction or symbolic link out of its root folder. | |
| Henrick Wibell Hellström 2026-09-25 19:43:49 Registered user |
WHAT GOES WRONG
The SFTP server subsystem confines every SFTP user to a root folder. It checks a requested path by its name: '.' and '..' are resolved within the root, and the full path that results must begin with the root's. Windows, however, follows a junction or a symbolic link when a path is opened, and a check of the name cannot see one. A junction or symbolic link anywhere inside the served folder therefore takes SFTP users out of the root: they can list, read, write, create and delete through it, with the rights of the server process. The server refuses the SFTP request that creates links, so such a link has to be placed by someone with local write access to the served folder, or be there already. WHAT THE FIX DOES Before a request uses a path, the server asks Windows where the path really leads, and refuses it unless that is inside the root. A name that does not exist yet - a file or directory about to be created, a rename target - is judged by its nearest existing parent folder, and a link whose target is gone is refused. When a file is opened, the opened file is checked the same way, so a link put in the way between the check and the open cannot redirect the file's contents either. A root folder that is itself reached through a junction keeps working. WHO NEEDS IT Applications that serve SFTP with the SFTP server subsystem of 4.1.1.354 (StreamSec.SFTP.ServerSubSystem). APPLIES TO StreamSec Tools 4.1.1.354, in StreamSec.SFTP.ServerSubSystem.pas, the release in which the SFTP server first shipped. 4.1.2.355 and later confine paths differently, and this fix does not apply to them; 4.1.2.355 to 4.1.3.359 have a fix of their own. HOW TO APPLY - three edits in StreamSec.SFTP.ServerSubSystem.pas 1. In the implementation section, directly before the line constructor tSFTPJail.Create(const aPhysicalRoot: string); insert the routines of PART 1 below. 2. At the end of function tSFTPJail.MapToPhysical, find the lines if not (SameText(lFull,fRoot) or StartsText(lRootD,lFull)) then Exit; aPhysical := lFull; and insert the lines of PART 2 between "Exit;" and "aPhysical := lFull;". 3. In procedure tSFTPServerSubSystem.DoOpen, find the line lH.Stream := TFileStream.Create(lPhys,lMode); and insert the lines of PART 3 directly after it. Then rebuild the packages, and the applications that serve SFTP. The fix is pure Pascal; no assembler is involved. -- PART 1 ---------------------------------------------------------------------- { ------------------------------------------ where a path really leads -- } { The name checks in tSFTPJail.MapToPhysical cannot see a junction or a symbolic link: Windows follows them when the path is opened, so a link inside the root can lead out of it. These ask Windows where a path actually leads. } const cFinalPathFlags = FILE_NAME_NORMALIZED or VOLUME_NAME_DOS; function FinalPathOfHandle(aHandle: THandle; out aPath: string): Boolean; var lNeed: DWORD; lBuf: string; begin Result := False; aPath := ''; lNeed := GetFinalPathNameByHandleW(aHandle,nil,0,cFinalPathFlags); if (lNeed = 0) or (lNeed > 40000) then Exit; SetLength(lBuf,lNeed + 1); lNeed := GetFinalPathNameByHandleW(aHandle,PWideChar(lBuf),lNeed + 1, cFinalPathFlags); if (lNeed = 0) or (lNeed > DWORD(Length(lBuf))) then Exit; SetLength(lBuf,lNeed); aPath := lBuf; Result := True; end; { Opens aPath following every link, as the server's own open does, and returns where it leads; aError is the Windows error when it cannot be opened. } function FinalPathOfName(const aPath: string; out aFinal: string; out aError: DWORD): Boolean; var lH: THandle; lPath: string; begin Result := False; aFinal := ''; lPath := aPath; if (Length(lPath) = 2) and (lPath[2] = ':') then lPath := lPath + ''; // a drive root, not the drive's // current directory lH := CreateFileW(PWideChar(lPath),FILE_READ_ATTRIBUTES, FILE_SHARE_READ or FILE_SHARE_WRITE or FILE_SHARE_DELETE, nil,OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS,0); if lH = INVALID_HANDLE_VALUE then begin aError := GetLastError; Exit; end; aError := 0; try Result := FinalPathOfHandle(lH,aFinal); finally CloseHandle(lH); end; end; function InsideFinalRoot(const aRootFinal, aFinal: string): Boolean; begin Result := SameText(aFinal,aRootFinal) or StartsText(IncludeTrailingPathDelimiter(aRootFinal),aFinal); end; { True when aFull - already inside aRoot by name - also leads inside it on disk. A name that does not exist yet (a file or directory about to be created, a rename target) is judged by its nearest existing parent. A name that exists but cannot be followed - a link whose target is gone - is refused, and so is anything Windows will not open for another reason. } function LeadsInsideRoot(const aRoot, aFull: string): Boolean; var lRootFinal, lFinal, lProbe: string; lError: DWORD; begin Result := False; if not FinalPathOfName(aRoot,lRootFinal,lError) then Exit; lProbe := aFull; while not FinalPathOfName(lProbe,lFinal,lError) do begin if ((lError <> ERROR_FILE_NOT_FOUND) and (lError <> ERROR_PATH_NOT_FOUND)) or (GetFileAttributesW(PWideChar(lProbe)) <> INVALID_FILE_ATTRIBUTES) or (Length(lProbe) <= Length(aRoot)) then Exit; lProbe := ExtractFileDir(lProbe); end; Result := InsideFinalRoot(lRootFinal,lFinal); end; { The same question for a file already opened: where does its handle lead. } function HandleInsideRoot(const aRoot: string; aHandle: THandle): Boolean; var lRootFinal, lFinal: string; lError: DWORD; begin Result := FinalPathOfName(aRoot,lRootFinal,lError) and FinalPathOfHandle(aHandle,lFinal) and InsideFinalRoot(lRootFinal,lFinal); end; -- PART 2 ---------------------------------------------------------------------- // It must also lead inside the jail ON DISK: a junction or symbolic link in // the tree would take it out. if not LeadsInsideRoot(fRoot,lFull) then Exit; -- PART 3 ---------------------------------------------------------------------- // What was opened must lie inside the jail as well: the path was checked // a moment ago, by name, and a link could have been put in its way since. if not HandleInsideRoot(fJail.Root,lH.Stream.Handle) then RaiseLastOSError(ERROR_ACCESS_DENIED); -------------------------------------------------------------------------------- TESTED A test puts a junction inside the served folder that leads out of it, and one whose target is gone, and serves a root folder that is itself reached through a junction. Unpatched, 4.1.1.354 maps paths through both junctions - to read, to create a file, to make a directory - to places outside the root. Patched, it refuses every one of them and still maps every ordinary path, on Win32 and Win64. The SFTP self-test, which runs our own SFTP server and client against each other over SSH, passes unchanged with the fix. |